diff --git a/www/html/Game/data/quiz-settings.json b/www/html/Game/data/quiz-settings.json
index e2eb3f9..d44145e 100644
--- a/www/html/Game/data/quiz-settings.json
+++ b/www/html/Game/data/quiz-settings.json
@@ -300,44 +300,604 @@
],
"carryQuestions": [
{
- "text": "ผู้มีอํานาจในการพิจารณาพิพากษาตัดสินคดีความในศาล",
+ "text": "ผู้มีอำนาจในการพิจารณาพิพากษาตัดสินคดีความในศาล",
"choices": [
- "จําเลย",
- "ผู้เสียหาย",
- "อัยการ",
- "ทนายความ",
- "โจทก์",
- "ตํารวจ",
- "ผู้ไกล่เกลี่ย",
- "ผู้พิพากษา",
- "นิติกร",
"พยานบุคคล",
+ "การประกันตัว",
+ "ผู้พิพากษา",
+ "การกระทำความผิด",
+ "จำเลย",
+ "ผู้เสียหาย",
+ "การอุทธรณ์",
+ "ผู้ไกล่เกลี่ย",
"หมายค้น",
+ "ศาลฎีกา",
+ "คดีแพ่ง",
+ "อาญาแผ่นดิน",
+ "อายุความ",
+ "คดีอาญา"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "text": "ผู้ที่ถูกกล่าวหาว่ากระทำความผิดทางอาญา และถูกฟ้องต่อศาลแล้ว",
+ "choices": [
+ "อาญาแผ่นดิน",
+ "อายุความ",
"หมายจับ",
- "หมายเรียก",
- "พยานหลักฐาน"
+ "พยานบุคคล",
+ "การประกันตัว",
+ "โจทก์",
+ "หมิ่นประมาท",
+ "จำเลย",
+ "ศาลฎีกา",
+ "ทนายความ",
+ "หมายค้น",
+ "นิติกร",
+ "การอุทธรณ์",
+ "คดีอาญา"
],
"correctIndex": 7
},
{
- "text": "ผู้ที่ถูกกล่าวหาว่ากระทําความผิดทางอาญา และถูกฟ้องต่อศาลแล้ว",
+ "text": "ผู้ที่ได้รับความเสียหายจากการกระทำความผิดทางอาญาของผู้อื่น",
"choices": [
- "ผู้พิพากษา",
- "โจทก์",
- "พยานบุคคล",
- "การประกันตัว",
- "นิติกร",
- "ผู้เสียหาย",
- "สัญญา",
+ "หมายค้น",
+ "พยานหลักฐาน",
+ "ตำรวจ",
+ "เรือนจำ",
+ "คำพิพากษา",
+ "อายุความ",
"หมายจับ",
- "ตํารวจ",
+ "อายัดทรัพย์",
+ "การอุทธรณ์",
+ "หมายเรียก",
"ผู้ไกล่เกลี่ย",
- "อัยการ",
- "ทนายความ",
- "จําเลย",
- "พยานหลักฐาน"
+ "ผู้พิพากษา",
+ "ผู้เสียหาย",
+ "รัฐธรรมนูญ"
],
"correctIndex": 12
+ },
+ {
+ "text": "เจ้าพนักงานผู้มีหน้าที่ฟ้องผู้ต้องหาต่อศาลแทนรัฐ",
+ "choices": [
+ "การประกันตัว",
+ "ทนายความ",
+ "ผู้ไกล่เกลี่ย",
+ "อัยการ",
+ "สัญญา",
+ "คำพิพากษา",
+ "การกระทำความผิด",
+ "หมายค้น",
+ "คดีแพ่ง",
+ "คดีอาญา",
+ "ตำรวจ",
+ "โจทก์",
+ "จำเลย",
+ "อายุความ"
+ ],
+ "correctIndex": 3
+ },
+ {
+ "text": "ผู้ที่ทำหน้าที่ให้คำปรึกษาและว่าความแทนคู่ความในศาล",
+ "choices": [
+ "นิติกร",
+ "หมิ่นประมาท",
+ "คดีแพ่ง",
+ "อาญาแผ่นดิน",
+ "หมายเรียก",
+ "หลักทรัพย์",
+ "รัฐธรรมนูญ",
+ "ผู้เสียหาย",
+ "ทนายความ",
+ "ผู้พิพากษา",
+ "อายัดทรัพย์",
+ "พยานบุคคล",
+ "การกระทำความผิด",
+ "ตำรวจ"
+ ],
+ "correctIndex": 8
+ },
+ {
+ "text": "ผู้ที่ยื่นฟ้องคดีต่อศาล (มักใช้เรียกในคดีแพ่ง)",
+ "choices": [
+ "นิติกร",
+ "จำเลย",
+ "หมิ่นประมาท",
+ "หลักทรัพย์",
+ "ผู้เสียหาย",
+ "พยานบุคคล",
+ "ผู้ไกล่เกลี่ย",
+ "ทนายความ",
+ "การอุทธรณ์",
+ "คำพิพากษา",
+ "คดีอาญา",
+ "ผู้พิพากษา",
+ "การประกันตัว",
+ "โจทก์"
+ ],
+ "correctIndex": 13
+ },
+ {
+ "text": "เจ้าพนักงานผู้มีหน้าที่รักษาความสงบเรียบร้อย สืบสวน และจับกุมผู้กระทำผิด",
+ "choices": [
+ "อัยการ",
+ "อาญาแผ่นดิน",
+ "อายัดทรัพย์",
+ "การกระทำความผิด",
+ "ตำรวจ",
+ "พยานบุคคล",
+ "เรือนจำ",
+ "โจทก์",
+ "พยานหลักฐาน",
+ "คำพิพากษา",
+ "รับสารภาพ",
+ "รัฐธรรมนูญ",
+ "การอุทธรณ์",
+ "อายุความ"
+ ],
+ "correctIndex": 4
+ },
+ {
+ "text": "ผู้ที่มีหน้าที่เป็นคนกลางช่วยเจรจาข้อพิพาทให้คู่ความตกลงกันได้",
+ "choices": [
+ "พยานบุคคล",
+ "อายุความ",
+ "ผู้เสียหาย",
+ "สัญญา",
+ "รัฐธรรมนูญ",
+ "คำพิพากษา",
+ "การกระทำความผิด",
+ "อัยการ",
+ "พยานหลักฐาน",
+ "ผู้ไกล่เกลี่ย",
+ "หมายจับ",
+ "ผู้พิพากษา",
+ "อาญาแผ่นดิน",
+ "หมายเรียก"
+ ],
+ "correctIndex": 9
+ },
+ {
+ "text": "ผู้เชี่ยวชาญหรือเจ้าหน้าที่ด้านกฎหมายที่ให้คำแนะนำในหน่วยงานหรือองค์กร",
+ "choices": [
+ "นิติกร",
+ "รัฐธรรมนูญ",
+ "ตำรวจ",
+ "อายัดทรัพย์",
+ "อาญาแผ่นดิน",
+ "หลักทรัพย์",
+ "หมายค้น",
+ "โจทก์",
+ "พยานหลักฐาน",
+ "สัญญา",
+ "รับสารภาพ",
+ "พยานบุคคล",
+ "เรือนจำ",
+ "ผู้ไกล่เกลี่ย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "text": "ผู้ที่มาให้ถ้อยคำในศาลเกี่ยวกับเหตุการณ์ที่ตนได้รู้ได้เห็นมา",
+ "choices": [
+ "รัฐธรรมนูญ",
+ "คดีแพ่ง",
+ "คำพิพากษา",
+ "อายัดทรัพย์",
+ "สัญญา",
+ "พยานบุคคล",
+ "พยานหลักฐาน",
+ "ทนายความ",
+ "หมายเรียก",
+ "อายุความ",
+ "คดีอาญา",
+ "หมายจับ",
+ "การอุทธรณ์",
+ "หมายค้น"
+ ],
+ "correctIndex": 5
+ },
+ {
+ "text": "หนังสือสั่งการของศาลที่อนุญาตให้เจ้าพนักงานเข้าไปค้นสถานที่เพื่อหาหลักฐาน",
+ "choices": [
+ "จำเลย",
+ "ผู้พิพากษา",
+ "พยานบุคคล",
+ "เรือนจำ",
+ "หมิ่นประมาท",
+ "การประกันตัว",
+ "อาญาแผ่นดิน",
+ "ศาลฎีกา",
+ "คดีแพ่ง",
+ "อายัดทรัพย์",
+ "หมายค้น",
+ "รัฐธรรมนูญ",
+ "หมายจับ",
+ "นิติกร"
+ ],
+ "correctIndex": 10
+ },
+ {
+ "text": "หนังสือสั่งการของศาลที่ให้เจ้าพนักงานจับกุมตัวบุคคลที่ระบุชื่อไว้",
+ "choices": [
+ "เรือนจำ",
+ "หมายจับ",
+ "ผู้ไกล่เกลี่ย",
+ "อาญาแผ่นดิน",
+ "ผู้เสียหาย",
+ "คดีอาญา",
+ "การกระทำความผิด",
+ "คดีแพ่ง",
+ "พยานหลักฐาน",
+ "จำเลย",
+ "พยานบุคคล",
+ "ตำรวจ",
+ "รับสารภาพ",
+ "ศาลฎีกา"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "text": "หนังสือที่ศาลหรือเจ้าพนักงานสั่งให้บุคคลใดบุคคลหนึ่งมาพบเพื่อสอบสวนหรือเบิกความ",
+ "choices": [
+ "การประกันตัว",
+ "พยานบุคคล",
+ "ศาลฎีกา",
+ "รับสารภาพ",
+ "ผู้เสียหาย",
+ "อายัดทรัพย์",
+ "หมายเรียก",
+ "อายุความ",
+ "เรือนจำ",
+ "อัยการ",
+ "คำพิพากษา",
+ "จำเลย",
+ "ผู้ไกล่เกลี่ย",
+ "อาญาแผ่นดิน"
+ ],
+ "correctIndex": 6
+ },
+ {
+ "text": "กฎหมายสูงสุดที่ใช้ในการปกครองประเทศ กฎหมายอื่นจะขัดแย้งไม่ได้",
+ "choices": [
+ "เรือนจำ",
+ "ตำรวจ",
+ "หมิ่นประมาท",
+ "สัญญา",
+ "ผู้เสียหาย",
+ "พยานบุคคล",
+ "อายัดทรัพย์",
+ "หมายจับ",
+ "ผู้ไกล่เกลี่ย",
+ "การประกันตัว",
+ "คำพิพากษา",
+ "รัฐธรรมนูญ",
+ "หมายค้น",
+ "คดีอาญา"
+ ],
+ "correctIndex": 11
+ },
+ {
+ "text": "เอกสารที่บันทึกข้อตกลงที่ผูกพันตามกฎหมายระหว่างบุคคลตั้งแต่สองฝ่ายขึ้นไป",
+ "choices": [
+ "อัยการ",
+ "ผู้เสียหาย",
+ "สัญญา",
+ "หมายค้น",
+ "คดีแพ่ง",
+ "พยานหลักฐาน",
+ "เรือนจำ",
+ "การอุทธรณ์",
+ "อายัดทรัพย์",
+ "หลักทรัพย์",
+ "การกระทำความผิด",
+ "พยานบุคคล",
+ "ตำรวจ",
+ "หมายจับ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "text": "การใส่ความผู้อื่นต่อบุคคลที่สาม ทำให้ผู้อื่นนั้นเสียชื่อเสียง ถูกดูหมิ่น หรือถูกเกลียดชัง",
+ "choices": [
+ "ผู้เสียหาย",
+ "อายัดทรัพย์",
+ "รับสารภาพ",
+ "ทนายความ",
+ "ศาลฎีกา",
+ "สัญญา",
+ "หมายค้น",
+ "หมิ่นประมาท",
+ "โจทก์",
+ "อายุความ",
+ "คดีอาญา",
+ "ผู้ไกล่เกลี่ย",
+ "ตำรวจ",
+ "จำเลย"
+ ],
+ "correctIndex": 7
+ },
+ {
+ "text": "ความผิดที่ยอมความกันไม่ได้ รัฐต้องดำเนินคดีแม้ผู้เสียหายจะไม่เอาความแล้วก็ตาม",
+ "choices": [
+ "คำพิพากษา",
+ "อัยการ",
+ "คดีแพ่ง",
+ "หมายค้น",
+ "ทนายความ",
+ "ผู้พิพากษา",
+ "การประกันตัว",
+ "รัฐธรรมนูญ",
+ "คดีอาญา",
+ "ตำรวจ",
+ "การอุทธรณ์",
+ "พยานบุคคล",
+ "อาญาแผ่นดิน",
+ "ผู้ไกล่เกลี่ย"
+ ],
+ "correctIndex": 12
+ },
+ {
+ "text": "การวางเงินหรือหลักทรัพย์เพื่อขอให้ปล่อยตัวชั่วคราวผู้ต้องหาหรือจำเลย",
+ "choices": [
+ "หมายค้น",
+ "ผู้เสียหาย",
+ "จำเลย",
+ "การประกันตัว",
+ "ศาลฎีกา",
+ "เรือนจำ",
+ "คำพิพากษา",
+ "คดีแพ่ง",
+ "อาญาแผ่นดิน",
+ "หมายเรียก",
+ "คดีอาญา",
+ "ทนายความ",
+ "โจทก์",
+ "สัญญา"
+ ],
+ "correctIndex": 3
+ },
+ {
+ "text": "สิ่งที่นำมาพิสูจน์ความจริงในศาล เช่น วัตถุ เอกสาร รอยนิ้วมือ",
+ "choices": [
+ "อายัดทรัพย์",
+ "หมายจับ",
+ "คดีอาญา",
+ "การประกันตัว",
+ "ผู้พิพากษา",
+ "สัญญา",
+ "ทนายความ",
+ "พยานบุคคล",
+ "พยานหลักฐาน",
+ "ผู้เสียหาย",
+ "นิติกร",
+ "ตำรวจ",
+ "จำเลย",
+ "หมิ่นประมาท"
+ ],
+ "correctIndex": 8
+ },
+ {
+ "text": "คดีที่เกี่ยวกับข้อพิพาทเรื่องสิทธิและหน้าที่ระหว่างบุคคล เช่น เรื่องหนี้สิน มรดก",
+ "choices": [
+ "คำพิพากษา",
+ "นิติกร",
+ "อาญาแผ่นดิน",
+ "หมิ่นประมาท",
+ "ผู้เสียหาย",
+ "รัฐธรรมนูญ",
+ "หมายจับ",
+ "ผู้พิพากษา",
+ "การกระทำความผิด",
+ "โจทก์",
+ "อายัดทรัพย์",
+ "รับสารภาพ",
+ "การอุทธรณ์",
+ "คดีแพ่ง"
+ ],
+ "correctIndex": 13
+ },
+ {
+ "text": "คดีที่เกี่ยวกับความผิดและมีบทลงโทษ เช่น การจำคุก การปรับ",
+ "choices": [
+ "เรือนจำ",
+ "คำพิพากษา",
+ "อายุความ",
+ "หมิ่นประมาท",
+ "คดีอาญา",
+ "อาญาแผ่นดิน",
+ "รับสารภาพ",
+ "จำเลย",
+ "หมายค้น",
+ "การประกันตัว",
+ "ผู้เสียหาย",
+ "ผู้ไกล่เกลี่ย",
+ "อายัดทรัพย์",
+ "ตำรวจ"
+ ],
+ "correctIndex": 4
+ },
+ {
+ "text": "ระยะเวลาที่กฎหมายกำหนดให้ผู้เสียหายต้องนำคดีมาฟ้องร้อง หากเลยกำหนดจะฟ้องไม่ได้",
+ "choices": [
+ "การอุทธรณ์",
+ "หมายจับ",
+ "สัญญา",
+ "หมิ่นประมาท",
+ "จำเลย",
+ "ศาลฎีกา",
+ "เรือนจำ",
+ "การกระทำความผิด",
+ "ผู้พิพากษา",
+ "อายุความ",
+ "พยานบุคคล",
+ "ตำรวจ",
+ "ผู้เสียหาย",
+ "ทนายความ"
+ ],
+ "correctIndex": 9
+ },
+ {
+ "text": "การตัดสินหรือชี้ขาดข้อพิพาทของศาล",
+ "choices": [
+ "คำพิพากษา",
+ "หมิ่นประมาท",
+ "สัญญา",
+ "ศาลฎีกา",
+ "ผู้พิพากษา",
+ "หมายค้น",
+ "พยานบุคคล",
+ "อัยการ",
+ "คดีอาญา",
+ "อาญาแผ่นดิน",
+ "โจทก์",
+ "ตำรวจ",
+ "การอุทธรณ์",
+ "รับสารภาพ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "text": "การขอให้ศาลที่สูงกว่า (ศาลอุทธรณ์) ทบทวนหรือพิจารณาคำพิพากษาของศาลชั้นต้นใหม่",
+ "choices": [
+ "อัยการ",
+ "อายัดทรัพย์",
+ "โจทก์",
+ "อายุความ",
+ "การกระทำความผิด",
+ "การอุทธรณ์",
+ "หมายค้น",
+ "อาญาแผ่นดิน",
+ "ผู้เสียหาย",
+ "เรือนจำ",
+ "ผู้ไกล่เกลี่ย",
+ "คดีแพ่ง",
+ "นิติกร",
+ "จำเลย"
+ ],
+ "correctIndex": 5
+ },
+ {
+ "text": "ศาลสูงสุดในระบบศาลยุติธรรมของประเทศไทย",
+ "choices": [
+ "อายุความ",
+ "พยานบุคคล",
+ "คดีอาญา",
+ "อายัดทรัพย์",
+ "หลักทรัพย์",
+ "การกระทำความผิด",
+ "โจทก์",
+ "นิติกร",
+ "ผู้ไกล่เกลี่ย",
+ "พยานหลักฐาน",
+ "ศาลฎีกา",
+ "ผู้เสียหาย",
+ "รัฐธรรมนูญ",
+ "จำเลย"
+ ],
+ "correctIndex": 10
+ },
+ {
+ "text": "ทรัพย์สินที่นำมาใช้เป็นประกันในการขอปล่อยตัวชั่วคราว",
+ "choices": [
+ "พยานบุคคล",
+ "หลักทรัพย์",
+ "เรือนจำ",
+ "คำพิพากษา",
+ "ผู้พิพากษา",
+ "ผู้เสียหาย",
+ "นิติกร",
+ "รัฐธรรมนูญ",
+ "อัยการ",
+ "โจทก์",
+ "คดีแพ่ง",
+ "พยานหลักฐาน",
+ "การกระทำความผิด",
+ "สัญญา"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "text": "สถานที่ที่ใช้ควบคุมตัวผู้ต้องขังหรือนักโทษตามคำสั่งศาล",
+ "choices": [
+ "การอุทธรณ์",
+ "คดีแพ่ง",
+ "ตำรวจ",
+ "คดีอาญา",
+ "รับสารภาพ",
+ "ผู้เสียหาย",
+ "เรือนจำ",
+ "หมายจับ",
+ "หลักทรัพย์",
+ "อายัดทรัพย์",
+ "พยานบุคคล",
+ "ทนายความ",
+ "โจทก์",
+ "อายุความ"
+ ],
+ "correctIndex": 6
+ },
+ {
+ "text": "การกระทำที่ฝ่าฝืนกฎหมายและมีบทลงโทษ",
+ "choices": [
+ "ศาลฎีกา",
+ "นิติกร",
+ "เรือนจำ",
+ "ตำรวจ",
+ "อายัดทรัพย์",
+ "คำพิพากษา",
+ "หมายเรียก",
+ "การอุทธรณ์",
+ "สัญญา",
+ "โจทก์",
+ "ทนายความ",
+ "การกระทำความผิด",
+ "หมายค้น",
+ "คดีอาญา"
+ ],
+ "correctIndex": 11
+ },
+ {
+ "text": "การยึดหรืออายัดทรัพย์สินไว้ชั่วคราวตามคำสั่ง เพื่อป้องกันการโยกย้ายหรือถ่ายเท",
+ "choices": [
+ "ผู้ไกล่เกลี่ย",
+ "ศาลฎีกา",
+ "อายัดทรัพย์",
+ "ทนายความ",
+ "ผู้เสียหาย",
+ "พยานหลักฐาน",
+ "โจทก์",
+ "อาญาแผ่นดิน",
+ "พยานบุคคล",
+ "หมิ่นประมาท",
+ "จำเลย",
+ "อายุความ",
+ "คำพิพากษา",
+ "รัฐธรรมนูญ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "text": "การที่ผู้กระทำผิดยอมรับสารภาพว่าได้กระทำความผิดจริงตามที่ถูกกล่าวหา",
+ "choices": [
+ "จำเลย",
+ "ผู้เสียหาย",
+ "คดีอาญา",
+ "การกระทำความผิด",
+ "ผู้พิพากษา",
+ "หมิ่นประมาท",
+ "อายุความ",
+ "รับสารภาพ",
+ "หลักทรัพย์",
+ "ผู้ไกล่เกลี่ย",
+ "พยานหลักฐาน",
+ "หมายค้น",
+ "ตำรวจ",
+ "สัญญา"
+ ],
+ "correctIndex": 7
}
],
"battleQuizMcq": [
diff --git a/www/html/Game/data/quiz-settings.json.bak-mg4q-103122 b/www/html/Game/data/quiz-settings.json.bak-mg4q-103122
new file mode 100644
index 0000000..e2eb3f9
--- /dev/null
+++ b/www/html/Game/data/quiz-settings.json.bak-mg4q-103122
@@ -0,0 +1,3847 @@
+{
+ "readMs": 15000,
+ "answerMs": 5000,
+ "betweenMs": 3000,
+ "carryReadMs": 3000,
+ "carryAnswerMs": 15000,
+ "carrySessionLength": 2,
+ "carryMapPanelTheme": {
+ "panelBg": "rgba(255, 0, 0, 0)",
+ "panelBorder": "rgba(255, 255, 255, 0)",
+ "borderWidthPx": 0,
+ "textColor": "rgba(241, 245, 249, 1)",
+ "questionFontMinPx": 10,
+ "questionFontMaxPx": 24
+ },
+ "quizMapPanelTheme": {
+ "panelBg": "rgba(12, 14, 28, 0)",
+ "panelBorder": "rgba(255, 214, 102, 0)",
+ "borderWidthPx": 2,
+ "textColor": "rgba(255, 224, 102, 1)",
+ "questionFontMinPx": 10,
+ "questionFontMaxPx": 28
+ },
+ "carryEmbedCountdownTheme": {
+ "overlayBackdrop": "rgba(8, 10, 20, 0)",
+ "innerBg": "rgba(12, 14, 28, 0)",
+ "innerBorder": "rgba(122, 162, 247, 0)",
+ "innerBorderWpx": 1,
+ "innerRadiusPx": 12,
+ "digitColor": "#f2f2f2",
+ "mapDigitCqmin": 78,
+ "mapDigitCqh": 82,
+ "mapDigitMaxPx": 200,
+ "screenDigitVw": 28,
+ "screenDigitMaxPx": 132
+ },
+ "carryChoicePlaqueThemes": [
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-2.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-2.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-1.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-1.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-1.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-3.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-3.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-4.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-4.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-5.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-5.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-5.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-6.png"
+ },
+ {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-6.png"
+ },
+ {
+ "borderMode": "neon",
+ "fixedBorder": "rgba(122, 200, 255, 0.9)",
+ "fillBg": "rgba(12, 10, 20, 0.88)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 2.5,
+ "plaqueImageUrl": ""
+ },
+ {
+ "borderMode": "neon",
+ "fixedBorder": "rgba(122, 200, 255, 0.9)",
+ "fillBg": "rgba(12, 10, 20, 0.88)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 2.5,
+ "plaqueImageUrl": ""
+ }
+ ],
+ "carryChoicePlaqueTheme": {
+ "borderMode": "fixed",
+ "fixedBorder": "rgba(122, 200, 255, 0)",
+ "fillBg": "rgba(12, 10, 20, 0)",
+ "textColor": "rgba(248, 249, 255, 1)",
+ "borderWidthPx": 0,
+ "plaqueImageUrl": "https://srv1361159.hstgr.cloud/Game/img/quiz-carry/board-2.png"
+ },
+ "carryChoicePlaqueMapScale": 1.9,
+ "carryWalkSpeedMultForMapId": "",
+ "carryWalkSpeedMult": null,
+ "quizRoundQuestionCount": 3,
+ "questions": [
+ {
+ "text": "เพื่อนทักขอยืมเงิน แต่ให้โอนเข้าบัญชีชื่อคนอื่น",
+ "answerTrue": false
+ },
+ {
+ "text": "ค่ายเน็ตโทรบอกคุณได้รางวัลใหญ่ ทั้งที่ไม่ได้ร่วมกิจกรรม",
+ "answerTrue": false
+ },
+ {
+ "text": "คนขายเร่งให้โอนมัดจำทันที อ้างว่ามีคิวรออยู่เยอะมาก",
+ "answerTrue": false
+ },
+ {
+ "text": "สายอัตโนมัติแจ้งว่าพัสดุมีปัญหา ให้กด 9 เพื่อคุยเจ้าหน้าที่",
+ "answerTrue": false
+ },
+ {
+ "text": "คนแปลกหน้าทักมาชวนคุย แล้วชวนลงทุนคริปโต",
+ "answerTrue": false
+ },
+ {
+ "text": "SMS: \"พัสดุถูกอายัด ชำระค่าปรับด่วนที่ [Link]\"",
+ "answerTrue": false
+ },
+ {
+ "text": "Email: \"คุณได้รับ iPhone 15 ฟรีจากสุ่มผู้ใช้ Google\"",
+ "answerTrue": false
+ },
+ {
+ "text": "Pop-up: \"เครื่องร้อนเกินไป! โหลดแอปนี้เพื่อระบายความร้อน\"",
+ "answerTrue": false
+ },
+ {
+ "text": "SMS: \"บัญชีธนาคารระงับ กรุณาเข้าลิงก์เพื่อยืนยันตัวตน\"",
+ "answerTrue": false
+ },
+ {
+ "text": "มีรหัส OTP ส่งเข้ามือถือ ทั้งที่เราไม่ได้ทำอะไรเลย",
+ "answerTrue": false
+ },
+ {
+ "text": "งานพาร์ทไทม์ \"กดไลก์สินค้า\" รายได้วันละ 1,000 บาท",
+ "answerTrue": false
+ },
+ {
+ "text": "รับสมัครงานแพ็คของที่บ้าน แต่ต้องจ่าย \"ค่ามัดจำอุปกรณ์\"",
+ "answerTrue": false
+ },
+ {
+ "text": "ชวนลงทุนหุ้นไทย แต่ให้โอนเงินเข้า \"บัญชีชื่อบุคคลธรรมดา\"",
+ "answerTrue": false
+ },
+ {
+ "text": "โฆษณาใช้รูปดาราหรือนักธุรกิจชื่อดังมาชวนลงทุน",
+ "answerTrue": false
+ },
+ {
+ "text": "คลิป TikTok สอนรวยด้วยการ \"ฝากเซียนเล่นพนันให้\"",
+ "answerTrue": false
+ },
+ {
+ "text": "แอปไฟฉาย ขอสิทธิ์เข้าถึง \"รายชื่อติดต่อ\" และ \"GPS\"",
+ "answerTrue": false
+ },
+ {
+ "text": "เจอ Wi-Fi สาธารณะชื่อ \"Free_Wifi_No_Password\"",
+ "answerTrue": false
+ },
+ {
+ "text": "แอปที่มีรีวิว 5 ดาวเยอะ แต่คอมเมนต์มีแต่คำซ้ำๆ แปลกๆ",
+ "answerTrue": false
+ },
+ {
+ "text": "เจอแฟลชไดร์ฟวางลืมไว้ เลยเอามาเสียบดูว่าของใคร",
+ "answerTrue": false
+ },
+ {
+ "text": "โฆษณา \"สอนปั่นบาคาร่า\" หรือ \"แจกสูตรโกงเว็บพนัน\"",
+ "answerTrue": false
+ },
+ {
+ "text": "หน้าจอ: \"Windows Update กำลังเตรียมการอัปเดต\"",
+ "answerTrue": true
+ },
+ {
+ "text": "โปรแกรมแอนตี้ไวรัสแจ้งว่า \"กักกันไฟล์อันตรายไว้ให้แล้ว\"",
+ "answerTrue": true
+ },
+ {
+ "text": "SMS แจ้งยอดถอนเงิน 1,000 บาท หลังเราเพิ่งถอนจริง",
+ "answerTrue": true
+ },
+ {
+ "text": "แอปธนาคารล็อคทันที เมื่อเรากด \"อัดหน้าจอ\" ทิ้งไว้",
+ "answerTrue": true
+ },
+ {
+ "text": "แจ้งเตือนการ Login ใหม่จาก Google เมื่อใช้เครื่องใหม่",
+ "answerTrue": true
+ },
+ {
+ "text": "ไรเดอร์โทรมาถามทางเพื่อส่งอาหารที่เราเพิ่งสั่ง",
+ "answerTrue": true
+ },
+ {
+ "text": "รหัส OTP ส่งมาขณะที่เรากำลังจ่ายเงินออนไลน์เอง",
+ "answerTrue": true
+ },
+ {
+ "text": "แอปในมือถือขออัปเดตผ่าน App Store / Play Store",
+ "answerTrue": true
+ },
+ {
+ "text": "บัญชีทางการของแบรนด์ที่มีเครื่องหมายติ๊กถูก (Verified)",
+ "answerTrue": true
+ },
+ {
+ "text": "เพจที่พักชื่อดัง (มีติ๊กถูก Verified) โพสต์โปรโมชั่นราคาปกติ",
+ "answerTrue": true
+ }
+ ],
+ "carryQuestions": [
+ {
+ "text": "ผู้มีอํานาจในการพิจารณาพิพากษาตัดสินคดีความในศาล",
+ "choices": [
+ "จําเลย",
+ "ผู้เสียหาย",
+ "อัยการ",
+ "ทนายความ",
+ "โจทก์",
+ "ตํารวจ",
+ "ผู้ไกล่เกลี่ย",
+ "ผู้พิพากษา",
+ "นิติกร",
+ "พยานบุคคล",
+ "หมายค้น",
+ "หมายจับ",
+ "หมายเรียก",
+ "พยานหลักฐาน"
+ ],
+ "correctIndex": 7
+ },
+ {
+ "text": "ผู้ที่ถูกกล่าวหาว่ากระทําความผิดทางอาญา และถูกฟ้องต่อศาลแล้ว",
+ "choices": [
+ "ผู้พิพากษา",
+ "โจทก์",
+ "พยานบุคคล",
+ "การประกันตัว",
+ "นิติกร",
+ "ผู้เสียหาย",
+ "สัญญา",
+ "หมายจับ",
+ "ตํารวจ",
+ "ผู้ไกล่เกลี่ย",
+ "อัยการ",
+ "ทนายความ",
+ "จําเลย",
+ "พยานหลักฐาน"
+ ],
+ "correctIndex": 12
+ }
+ ],
+ "battleQuizMcq": [
+ {
+ "categoryId": "topic1",
+ "text": "อายุที่บรรลุนิติภาวะตามกฎหมายไทยคือกี่ปี?",
+ "choices": [
+ "20 ปีบริบูรณ์",
+ "18 ปี",
+ "15 ปี"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การทำบัตรประชาชนครั้งแรกต้องทำเมื่ออายุเท่าใด?",
+ "choices": [
+ "10 ปี",
+ "7 ปี",
+ "15 ปี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "เอกสารใดใช้เป็นหลักฐานแสดงตนตามกฎหมาย?",
+ "choices": [
+ "บัตรสมาชิกห้าง",
+ "บัตรนักเรียนเก่า",
+ "บัตรประจำตัวประชาชน"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การหมั้นตามกฎหมายไทย ฝ่ายชายต้องมอบสิ่งใดให้ฝ่ายหญิง?",
+ "choices": [
+ "ของหมั้น",
+ "ค่าปรับ",
+ "ภาษี"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "พินัยกรรมคือเอกสารแสดงเจตนาเกี่ยวกับสิ่งใด?",
+ "choices": [
+ "การกู้เงิน",
+ "การจัดการทรัพย์สินหลังเสียชีวิต",
+ "การเช่าบ้าน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การจดทะเบียนสมรสทำได้ที่ใด?",
+ "choices": [
+ "ธนาคาร",
+ "โรงพยาบาล",
+ "ที่ว่าการอำเภอหรือสำนักงานเขต"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "ทำไมสัญญาเช่าบ้านจึงควรทำเป็นลายลักษณ์อักษร?",
+ "choices": [
+ "เป็นหลักฐานป้องกันข้อพิพาท",
+ "เพื่อเสียภาษีมากขึ้น",
+ "เพื่อความสวยงาม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "หากเก็บเงินที่ผู้อื่นทำหล่นได้ ควรทำอย่างไรตามกฎหมาย?",
+ "choices": [
+ "เก็บไว้เป็นของตน",
+ "ส่งคืนเจ้าของหรือแจ้งตำรวจ",
+ "ทิ้งไป"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การกู้ยืมเงินเกินกว่าเท่าใดควรมีหลักฐานเป็นหนังสือจึงฟ้องร้องได้?",
+ "choices": [
+ "50 บาท",
+ "1 ล้านบาท",
+ "2,000 บาท"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "ดอกเบี้ยเงินกู้ระหว่างบุคคลตามกฎหมายห้ามเกินร้อยละเท่าใดต่อปี?",
+ "choices": [
+ "ร้อยละ 15",
+ "ร้อยละ 50",
+ "ร้อยละ 100"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การรับบุตรบุญธรรมต้องจดทะเบียนที่ใด?",
+ "choices": [
+ "ศาล",
+ "ที่ว่าการอำเภอหรือสำนักงานเขต",
+ "สถานีตำรวจ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "เมื่อซื้อสินค้าชำรุด ผู้บริโภคมีสิทธิทำสิ่งใด?",
+ "choices": [
+ "ทำอะไรไม่ได้เลย",
+ "ต้องจ่ายเงินเพิ่ม",
+ "ขอเปลี่ยนหรือคืนตามเงื่อนไข"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การเซ็นค้ำประกันเงินกู้ให้ผู้อื่นหมายความว่าอย่างไร?",
+ "choices": [
+ "ต้องรับผิดชอบหนี้หากลูกหนี้ไม่ชำระ",
+ "ได้รับเงินฟรี",
+ "ไม่มีผลผูกพันใด"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "เอกสารสิทธิ์ที่ดินที่มีกรรมสิทธิ์สมบูรณ์ที่สุดคือ?",
+ "choices": [
+ "ใบจอง",
+ "โฉนดที่ดิน (น.ส.4)",
+ "ส.ป.ก."
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การแจ้งเกิดเด็กต้องแจ้งภายในกี่วัน?",
+ "choices": [
+ "ภายใน 1 ปี",
+ "ภายใน 90 วัน",
+ "ภายใน 15 วัน"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "หากถูกละเมิดสิทธิผู้บริโภค ร้องเรียนได้ที่หน่วยงานใด?",
+ "choices": [
+ "สำนักงานคุ้มครองผู้บริโภค (สคบ.)",
+ "กรมศุลกากร",
+ "กรมป่าไม้"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การทำสัญญาที่เกิดจากการถูกข่มขู่ มีผลอย่างไร?",
+ "choices": [
+ "สมบูรณ์เสมอ",
+ "เป็นโมฆียะ (บอกล้างได้)",
+ "ผิดอาญาทันที"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic1",
+ "text": "บุคคลใดทำนิติกรรมเองไม่ได้ ต้องมีผู้แทนโดยชอบธรรม?",
+ "choices": [
+ "ผู้ใหญ่ทั่วไป",
+ "ข้าราชการ",
+ "ผู้เยาว์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic1",
+ "text": "การเช่าซื้อรถยนต์ กรรมสิทธิ์จะเป็นของผู้เช่าซื้อเมื่อใด?",
+ "choices": [
+ "เมื่อชำระค่างวดครบตามสัญญา",
+ "ทันทีที่รับรถ",
+ "ไม่มีวันเป็นของผู้เช่าซื้อ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic1",
+ "text": "หนังสือมอบอำนาจใช้เพื่อสิ่งใด?",
+ "choices": [
+ "ยกทรัพย์ให้ฟรี",
+ "ให้ผู้อื่นทำการแทนตามที่ระบุ",
+ "จ่ายภาษีแทน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิเสรีภาพขั้นพื้นฐานของประชาชนได้รับการรับรองในกฎหมายใด?",
+ "choices": [
+ "รัฐธรรมนูญ",
+ "กฎกระทรวง",
+ "ประกาศของห้าง"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิในการเลือกตั้งระดับชาติ เริ่มเมื่ออายุเท่าใด?",
+ "choices": [
+ "15 ปี",
+ "18 ปี",
+ "25 ปี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "หลัก \"บุคคลย่อมเสมอกันตามกฎหมาย\" หมายถึงสิ่งใด?",
+ "choices": [
+ "คนรวยมีสิทธิมากกว่า",
+ "ผู้ชายเหนือกว่าผู้หญิง",
+ "ได้รับการคุ้มครองอย่างเท่าเทียม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "เสรีภาพในการนับถือศาสนา หมายความว่าอย่างไร?",
+ "choices": [
+ "เลือกนับถือศาสนาใดก็ได้",
+ "ต้องนับถือศาสนาเดียวเท่านั้น",
+ "ห้ามนับถือศาสนา"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "รัฐจัดการศึกษาขั้นพื้นฐานให้ฟรีกี่ปี?",
+ "choices": [
+ "5 ปี",
+ "15 ปี",
+ "3 ปี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "การถูกจับกุมโดยไม่มีหมายและไม่มีเหตุตามกฎหมาย ละเมิดสิ่งใด?",
+ "choices": [
+ "สิทธิในทรัพย์สิน",
+ "ลิขสิทธิ์",
+ "สิทธิเสรีภาพในร่างกาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "เสรีภาพในการแสดงความคิดเห็นมีขอบเขตอย่างไร?",
+ "choices": [
+ "ต้องไม่ละเมิดสิทธิผู้อื่นหรือผิดกฎหมาย",
+ "ทำอะไรก็ได้ไม่จำกัด",
+ "ห้ามพูดทุกอย่าง"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิของผู้ต้องหาในคดีอาญาข้อใดถูกต้อง?",
+ "choices": [
+ "ต้องถือว่าผิดทันที",
+ "ได้รับการสันนิษฐานว่าบริสุทธิ์จนกว่าจะพิสูจน์ได้",
+ "ห้ามมีทนายความ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "การเข้าถึงข้อมูลข่าวสารของราชการเป็นสิทธิตามกฎหมายใด?",
+ "choices": [
+ "กฎจราจร",
+ "กฎหมายแพ่ง",
+ "พ.ร.บ.ข้อมูลข่าวสารของราชการ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิความเป็นส่วนตัว (privacy) คุ้มครองสิ่งใด?",
+ "choices": [
+ "ข้อมูลส่วนบุคคลและชีวิตส่วนตัว",
+ "ทรัพย์สินสาธารณะ",
+ "ที่ดินของรัฐ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "ผู้พิการมีสิทธิได้รับสิ่งใดจากรัฐ?",
+ "choices": [
+ "ไม่มีสิทธิใด ๆ",
+ "สิ่งอำนวยความสะดวกและสวัสดิการ",
+ "เสียภาษีเพิ่ม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "เด็กมีสิทธิพื้นฐานข้อใด?",
+ "choices": [
+ "ทำงานหนักเท่าผู้ใหญ่",
+ "ไม่มีสิทธิใด ๆ",
+ "ได้รับการเลี้ยงดูและการศึกษา"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "เสรีภาพในการชุมนุมต้องเป็นไปอย่างไร?",
+ "choices": [
+ "โดยสงบและปราศจากอาวุธ",
+ "ใช้อาวุธได้",
+ "ทำลายทรัพย์สินได้"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิได้รับการพิจารณาคดีอย่างเป็นธรรมเรียกว่าอะไร?",
+ "choices": [
+ "สิทธิในการหลบหนี",
+ "สิทธิในกระบวนการยุติธรรมที่เป็นธรรม",
+ "สิทธิในการติดสินบน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "การเลือกปฏิบัติเพราะเพศ เชื้อชาติ หรือศาสนา กฎหมายมองว่าอย่างไร?",
+ "choices": [
+ "ทำได้อย่างเสรี",
+ "ควรส่งเสริม",
+ "ต้องห้ามและผิดกฎหมาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "หน่วยงานใดมีหน้าที่คุ้มครองสิทธิมนุษยชนในไทย?",
+ "choices": [
+ "คณะกรรมการสิทธิมนุษยชนแห่งชาติ",
+ "กรมที่ดิน",
+ "การไฟฟ้า"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิแรงงานข้อใดถูกต้อง?",
+ "choices": [
+ "ต้องทำงานฟรี",
+ "ได้รับค่าจ้างขั้นต่ำตามกฎหมาย",
+ "ห้ามหยุดพัก"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic2",
+ "text": "PDPA เป็นกฎหมายที่คุ้มครองสิ่งใด?",
+ "choices": [
+ "ที่ดิน",
+ "รถยนต์",
+ "ข้อมูลส่วนบุคคล"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic2",
+ "text": "สิทธิในการรักษาพยาบาลขั้นพื้นฐานของคนไทยเรียกว่าอะไร?",
+ "choices": [
+ "หลักประกันสุขภาพถ้วนหน้า (บัตรทอง)",
+ "ประกันรถยนต์",
+ "ภาษีมูลค่าเพิ่ม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic2",
+ "text": "การกระทำใดถือเป็นการละเมิดสิทธิมนุษยชน?",
+ "choices": [
+ "การไปใช้สิทธิเลือกตั้ง",
+ "การบังคับใช้แรงงานเด็กโดยไม่เต็มใจ",
+ "การแสดงความคิดเห็นอย่างสุภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "ผู้ขับขี่รถจักรยานยนต์ต้องสวมสิ่งใดทุกครั้ง?",
+ "choices": [
+ "หมวกนิรภัย",
+ "แว่นกันแดด",
+ "ถุงมือ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "สัญญาณไฟจราจรสีแดงหมายถึงอะไร?",
+ "choices": [
+ "ให้ไปได้",
+ "ให้หยุด",
+ "ให้เตรียมตัว"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การขับรถขณะเมาสุรา ระดับแอลกอฮอล์ในเลือดห้ามเกินเท่าใด (ผู้ขับทั่วไป)?",
+ "choices": [
+ "200 มิลลิกรัมเปอร์เซ็นต์",
+ "ไม่จำกัด",
+ "50 มิลลิกรัมเปอร์เซ็นต์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "ใบอนุญาตขับรถยนต์ส่วนบุคคลครั้งแรกออกให้ผู้มีอายุอย่างน้อยกี่ปี?",
+ "choices": [
+ "18 ปี",
+ "15 ปี",
+ "12 ปี"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การคาดเข็มขัดนิรภัยมีไว้เพื่อสิ่งใด?",
+ "choices": [
+ "เพิ่มความเร็วรถ",
+ "ลดการบาดเจ็บเมื่อเกิดอุบัติเหตุ",
+ "ประหยัดน้ำมัน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "เมื่อถึงทางม้าลายที่มีคนกำลังข้าม ผู้ขับขี่ควรทำอย่างไร?",
+ "choices": [
+ "เร่งความเร็วผ่านไป",
+ "บีบแตรไล่",
+ "ชะลอและหยุดให้คนข้าม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การใช้โทรศัพท์มือถือขณะขับรถโดยไม่มีอุปกรณ์เสริม มีผลอย่างไร?",
+ "choices": [
+ "ผิดกฎจราจร",
+ "ถูกต้องตามกฎหมาย",
+ "ได้คะแนนสะสม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "สัญญาณไฟเลี้ยวใช้เพื่อสิ่งใด?",
+ "choices": [
+ "ประดับรถให้สวย",
+ "บอกทิศทางแก่ผู้ร่วมทาง",
+ "ไล่แมลง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "เมื่อรถพยาบาลเปิดสัญญาณไซเรนมา ผู้ขับขี่ควรทำอย่างไร?",
+ "choices": [
+ "ขับขวางไว้",
+ "เร่งแซงขึ้นหน้า",
+ "หลีกทางให้"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การจอดรถในที่ห้ามจอดมีผลอย่างไร?",
+ "choices": [
+ "ถูกปรับหรือล็อกล้อ",
+ "ได้รับรางวัล",
+ "ไม่มีผลใด ๆ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "ป้ายจราจรวงกลมขอบแดงพื้นขาวโดยทั่วไปหมายถึงอะไร?",
+ "choices": [
+ "ป้ายแนะนำ",
+ "ป้ายบังคับหรือห้าม",
+ "ป้ายโฆษณา"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การขับรถย้อนศรเป็นการกระทำอย่างไร?",
+ "choices": [
+ "ปลอดภัยดี",
+ "ควรส่งเสริม",
+ "ผิดกฎหมายและอันตราย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "หากเกิดอุบัติเหตุมีผู้บาดเจ็บ ผู้ขับขี่ควรทำสิ่งใดก่อน?",
+ "choices": [
+ "ช่วยเหลือผู้บาดเจ็บและแจ้งเหตุ",
+ "รีบหลบหนี",
+ "ลบหลักฐาน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การชนแล้วหนีหลังก่ออุบัติเหตุ มีผลอย่างไร?",
+ "choices": [
+ "ไม่มีความผิด",
+ "มีความผิดตามกฎหมาย",
+ "ได้รับการลดโทษ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "เด็กเล็กควรนั่งรถยนต์อย่างไรให้ปลอดภัย?",
+ "choices": [
+ "นั่งบนตักคนขับ",
+ "ยืนหน้ารถ",
+ "ใช้ที่นั่งนิรภัยสำหรับเด็ก (คาร์ซีท)"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การบรรทุกเกินพิกัดน้ำหนักมีผลอย่างไร?",
+ "choices": [
+ "ผิดกฎหมายและเสี่ยงอุบัติเหตุ",
+ "ปลอดภัยมากขึ้น",
+ "ประหยัดเวลา"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การขับรถเร็วเกินกว่ากฎหมายกำหนดมีผลอย่างไร?",
+ "choices": [
+ "ได้รับรางวัล",
+ "ถูกปรับและเสี่ยงอันตราย",
+ "ไม่ผิดกฎหมาย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การแซงรถควรทำเมื่อใด?",
+ "choices": [
+ "ในทางโค้ง",
+ "บนสะพาน",
+ "เมื่อปลอดภัยและทัศนวิสัยดี"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic3",
+ "text": "พ.ร.บ. (ประกันภัยรถภาคบังคับ) มีไว้เพื่อสิ่งใด?",
+ "choices": [
+ "คุ้มครองผู้ประสบภัยจากรถ",
+ "ตกแต่งรถ",
+ "ลดภาษีบ้าน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic3",
+ "text": "การเปิดไฟหน้ารถในเวลากลางคืนมีไว้เพื่ออะไร?",
+ "choices": [
+ "เพิ่มความเร็ว",
+ "เพิ่มการมองเห็นและความปลอดภัย",
+ "ประหยัดแบตเตอรี่"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การเอาทรัพย์ของผู้อื่นไปโดยทุจริตเรียกว่าความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "หมิ่นประมาท",
+ "บุกรุก"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญเรียกว่าอะไร?",
+ "choices": [
+ "ยักยอกทรัพย์",
+ "ชิงทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การชิงทรัพย์โดยมีผู้ร่วมตั้งแต่สามคนขึ้นไปเรียกว่าอะไร?",
+ "choices": [
+ "ลักทรัพย์",
+ "รับของโจร",
+ "ปล้นทรัพย์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การหลอกลวงให้ผู้อื่นมอบทรัพย์ให้เรียกว่าความผิดฐานใด?",
+ "choices": [
+ "ฉ้อโกง",
+ "ลักทรัพย์",
+ "ทำร้ายร่างกาย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "ผู้ได้รับมอบหมายให้ดูแลทรัพย์แล้วเบียดบังเอาเป็นของตนเรียกว่าอะไร?",
+ "choices": [
+ "ชิงทรัพย์",
+ "ยักยอกทรัพย์",
+ "ปล้นทรัพย์"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การซื้อหรือรับทรัพย์ที่รู้ว่าได้มาจากการกระทำผิดเรียกว่าอะไร?",
+ "choices": [
+ "ลักทรัพย์",
+ "ฉ้อโกง",
+ "รับของโจร"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การทำให้ทรัพย์สินของผู้อื่นเสียหายเรียกว่าความผิดฐานใด?",
+ "choices": [
+ "ทำให้เสียทรัพย์",
+ "หมิ่นประมาท",
+ "บุกรุก"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การเข้าไปในเคหสถานของผู้อื่นโดยไม่มีสิทธิเรียกว่าอะไร?",
+ "choices": [
+ "ลักทรัพย์",
+ "บุกรุก",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "เมื่อถูกลักทรัพย์ควรทำอย่างไร?",
+ "choices": [
+ "แก้แค้นด้วยตนเอง",
+ "นิ่งเฉย",
+ "แจ้งความต่อเจ้าหน้าที่ตำรวจ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การลักทรัพย์ในเวลากลางคืนมีผลต่อโทษอย่างไร?",
+ "choices": [
+ "โทษหนักขึ้น (เหตุฉกรรจ์)",
+ "ไม่มีผลต่อโทษ",
+ "ได้ลดโทษ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การกรรโชกทรัพย์หมายถึงอะไร?",
+ "choices": [
+ "การขอยืมตามปกติ",
+ "ข่มขืนใจให้ผู้อื่นยอมให้ทรัพย์โดยขู่เข็ญ",
+ "การซื้อขายทั่วไป"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การยักยอกทรัพย์ต่างจากลักทรัพย์อย่างไร?",
+ "choices": [
+ "ต้องใช้กำลังเสมอ",
+ "ไม่มีความผิด",
+ "ผู้ครอบครองทรัพย์อยู่แล้วเบียดบังเอาไป"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การวิ่งราวทรัพย์หมายถึงอะไร?",
+ "choices": [
+ "ลักทรัพย์โดยฉกฉวยซึ่งหน้าแล้ววิ่งหนี",
+ "ปล้นโดยมีอาวุธ",
+ "การฉ้อโกง"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "ความผิดเกี่ยวกับทรัพย์ส่วนใหญ่อยู่ในกฎหมายใด?",
+ "choices": [
+ "กฎจราจร",
+ "ประมวลกฎหมายอาญา",
+ "กฎหมายแรงงาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การทำลายเอกสารสำคัญของผู้อื่นถือเป็นความผิดหรือไม่?",
+ "choices": [
+ "ไม่ผิด",
+ "ควรส่งเสริม",
+ "เป็นความผิด"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การปล้นทรัพย์โดยมีอาวุธมีผลต่อโทษอย่างไร?",
+ "choices": [
+ "โทษหนักขึ้น",
+ "ได้ลดโทษ",
+ "ไม่มีผล"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "คดีลักทรัพย์ระหว่างญาติบางกรณีจัดเป็นความผิดประเภทใด?",
+ "choices": [
+ "คดีอุกฉกรรจ์เสมอ",
+ "ความผิดอันยอมความได้",
+ "คดีแพ่งล้วน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic4",
+ "text": "การฉ้อโกงประชาชนต่างจากฉ้อโกงทั่วไปอย่างไร?",
+ "choices": [
+ "เหมือนกันทุกอย่าง",
+ "ไม่เป็นความผิด",
+ "หลอกลวงคนจำนวนมาก โทษหนักขึ้น"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic4",
+ "text": "ทรัพย์ที่ถูกลักไปแล้วได้คืน เจ้าของควรทำอย่างไร?",
+ "choices": [
+ "รับคืนและดำเนินคดีตามกฎหมาย",
+ "ทำลายทิ้ง",
+ "นำไปขายต่อ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic4",
+ "text": "สิ่งของที่ผู้อื่นทำหายแล้วเก็บได้ หากเบียดบังเป็นของตนผิดฐานใด?",
+ "choices": [
+ "ปล้นทรัพย์",
+ "ยักยอกทรัพย์สินหาย",
+ "ทำร้ายร่างกาย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การใส่ความผู้อื่นต่อบุคคลที่สามจนเสียชื่อเสียงเรียกว่าอะไร?",
+ "choices": [
+ "หมิ่นประมาท",
+ "ลักทรัพย์",
+ "บุกรุก"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การหมิ่นประมาทด้วยการโฆษณาหรือสื่อสิ่งพิมพ์ มีผลต่อโทษอย่างไร?",
+ "choices": [
+ "ได้ลดโทษ",
+ "โทษหนักขึ้น",
+ "ไม่เป็นความผิด"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การหมิ่นประมาททางออนไลน์เข้าข่ายกฎหมายใดเพิ่มเติม?",
+ "choices": [
+ "กฎจราจร",
+ "กฎหมายแรงงาน",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "ข้อใดอาจเป็นเหตุยกเว้นความผิดฐานหมิ่นประมาท?",
+ "choices": [
+ "การติชมโดยสุจริตและเป็นธรรม",
+ "การใส่ร้ายด้วยข้อมูลเท็จ",
+ "การนินทาให้เสียหาย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การหมิ่นประมาทบุคคลทั่วไปเป็นความผิดประเภทใด?",
+ "choices": [
+ "คดีอุกฉกรรจ์",
+ "ความผิดอันยอมความได้",
+ "คดีแพ่งล้วน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การด่าทอผู้อื่นซึ่งหน้าด้วยถ้อยคำหยาบคายเข้าข่ายความผิดใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ฉ้อโกง",
+ "ดูหมิ่นซึ่งหน้า"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "ผู้เสียหายจากการหมิ่นประมาทสามารถทำสิ่งใดได้?",
+ "choices": [
+ "ฟ้องร้องเรียกค่าเสียหาย",
+ "ทำร้ายผู้กระทำ",
+ "ยึดทรัพย์เอง"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การแชร์ข้อความหมิ่นประมาทผู้อื่นต่อ มีความเสี่ยงอย่างไร?",
+ "choices": [
+ "ปลอดภัยเสมอ",
+ "อาจมีความผิดร่วมด้วย",
+ "ได้รับรางวัล"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "องค์ประกอบสำคัญของการหมิ่นประมาทคืออะไร?",
+ "choices": [
+ "การพูดกับตัวเองคนเดียว",
+ "การคิดอยู่ในใจ",
+ "การใส่ความต่อบุคคลที่สาม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การลงรูปผู้อื่นพร้อมข้อความใส่ร้ายในโซเชียลมีเดียผิดหรือไม่?",
+ "choices": [
+ "ผิด",
+ "ไม่ผิด",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "หากถูกหมิ่นประมาทออนไลน์ ควรทำอย่างไร?",
+ "choices": [
+ "ลบทุกอย่างทันที",
+ "เก็บหลักฐานและแจ้งความ",
+ "ตอบโต้ด้วยการด่ากลับ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การวิจารณ์สินค้าหรือบริการตามความจริงโดยสุจริตเป็นอย่างไร?",
+ "choices": [
+ "ผิดกฎหมายเสมอ",
+ "ต้องติดคุกทันที",
+ "โดยทั่วไปทำได้"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "โทษของการหมิ่นประมาทโดยทั่วไปคืออะไร?",
+ "choices": [
+ "ปรับและ/หรือจำคุกตามที่กฎหมายกำหนด",
+ "ไม่มีโทษใด ๆ",
+ "ได้รับรางวัล"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "ความแตกต่างระหว่าง \"ดูหมิ่น\" กับ \"หมิ่นประมาท\" คือ?",
+ "choices": [
+ "เหมือนกันทุกประการ",
+ "ดูหมิ่นคือสบประมาทซึ่งหน้า หมิ่นประมาทคือใส่ความต่อผู้อื่น",
+ "ไม่เป็นความผิดทั้งคู่"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การกล่าวหาผู้อื่นว่ากระทำผิดโดยไม่มีมูลความจริง เข้าข่ายใด?",
+ "choices": [
+ "ถูกต้องตามกฎหมาย",
+ "ควรส่งเสริม",
+ "หมิ่นประมาทหรือแจ้งความเท็จ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "ผู้กระทำหมิ่นประมาทอาจต้องรับผิดทางใดบ้าง?",
+ "choices": [
+ "ทั้งทางอาญาและทางแพ่ง",
+ "ไม่มีความรับผิดใด",
+ "ได้รับเฉพาะรางวัล"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การขอโทษและแก้ไขข้อความ มีผลอย่างไรในคดีหมิ่นประมาท?",
+ "choices": [
+ "ไม่มีผลใด ๆ",
+ "อาจช่วยบรรเทาโทษหรือยอมความ",
+ "เพิ่มโทษให้หนักขึ้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic5",
+ "text": "สื่อมวลชนรายงานข่าวตามข้อเท็จจริงเพื่อประโยชน์สาธารณะ โดยทั่วไปเป็นอย่างไร?",
+ "choices": [
+ "ผิดกฎหมายเสมอ",
+ "ต้องปิดสำนักงาน",
+ "ได้รับการคุ้มครองหากทำโดยสุจริต"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การหมิ่นประมาทผู้ที่เสียชีวิตไปแล้วมีกฎหมายเกี่ยวข้องหรือไม่?",
+ "choices": [
+ "มี เพราะกระทบต่อทายาท",
+ "ไม่มีเลย",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic5",
+ "text": "การพูดความจริงเพื่อประโยชน์สาธารณะโดยสุจริต โดยทั่วไปเป็นอย่างไร?",
+ "choices": [
+ "ผิดเสมอ",
+ "อาจไม่เป็นความผิด",
+ "ผิดร้ายแรงที่สุด"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การล่วงละเมิดทางเพศโดยผู้อื่นไม่ยินยอมเป็นความผิดประเภทใด?",
+ "choices": [
+ "ความผิดทางอาญาที่ร้ายแรง",
+ "เรื่องส่วนตัว",
+ "ไม่เป็นความผิด"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "\"ความยินยอม\" ในเรื่องเพศต้องเป็นอย่างไร?",
+ "choices": [
+ "บังคับให้ยินยอมได้",
+ "สมัครใจและรู้สึกตัว ไม่ถูกบังคับ",
+ "ไม่จำเป็นต้องมี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การล่วงละเมิดทางเพศเด็ก กฎหมายมองอย่างไร?",
+ "choices": [
+ "ไม่ผิดหากเด็กยินยอม",
+ "เป็นเรื่องเล็กน้อย",
+ "เป็นความผิดร้ายแรงแม้เด็กจะยินยอม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การคุกคามทางเพศในที่ทำงานเป็นอย่างไร?",
+ "choices": [
+ "เป็นความผิดและร้องเรียนได้",
+ "เป็นเรื่องปกติ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "ผู้เสียหายจากความผิดทางเพศควรทำอย่างไร?",
+ "choices": [
+ "นิ่งเฉยเสมอ",
+ "แจ้งความและขอความช่วยเหลือ",
+ "โทษตัวเอง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การเผยแพร่ภาพลับของผู้อื่นโดยไม่ยินยอมผิดกฎหมายใด?",
+ "choices": [
+ "ไม่ผิดกฎหมาย",
+ "กฎจราจร",
+ "กฎหมายอาญาและ พ.ร.บ.คอมพิวเตอร์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การแอบถ่ายภาพผู้อื่นในที่ลับโดยไม่ยินยอมเป็นอย่างไร?",
+ "choices": [
+ "เป็นความผิด",
+ "ทำได้ตามปกติ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "หน่วยงานใดช่วยเหลือผู้เสียหายจากความรุนแรงทางเพศ?",
+ "choices": [
+ "กรมที่ดิน",
+ "ศูนย์ช่วยเหลือสังคม สายด่วน 1300",
+ "การไฟฟ้า"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การล่อลวงเด็กเพื่อการค้าประเวณีเป็นความผิดอย่างไร?",
+ "choices": [
+ "ไม่เป็นความผิด",
+ "เป็นเรื่องเล็กน้อย",
+ "ความผิดร้ายแรง โทษหนัก"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การส่งข้อความหรือรูปลามกให้ผู้อื่นที่ไม่ต้องการรับ เป็นอย่างไร?",
+ "choices": [
+ "เป็นการคุกคามและอาจผิดกฎหมาย",
+ "เป็นการสุภาพ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "หลักฐานสำคัญในคดีล่วงละเมิดทางเพศควรทำอย่างไร?",
+ "choices": [
+ "ทำลายทิ้ง",
+ "เก็บรักษาและรีบแจ้งเจ้าหน้าที่",
+ "เพิกเฉย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การข่มขืนกระทำชำเราโดยมีอาวุธหรือร่วมกันหลายคน มีผลต่อโทษอย่างไร?",
+ "choices": [
+ "ได้ลดโทษ",
+ "ไม่มีผล",
+ "โทษหนักขึ้น"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "เด็กและเยาวชนที่เป็นผู้เสียหายได้รับการคุ้มครองอย่างไร?",
+ "choices": [
+ "ปกปิดข้อมูลและสอบสวนเป็นพิเศษ",
+ "เปิดเผยต่อสาธารณะ",
+ "ไม่มีการคุ้มครอง"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การกระทำอนาจารต่อผู้อื่นโดยไม่ยินยอมเป็นความผิดหรือไม่?",
+ "choices": [
+ "ไม่ผิด",
+ "เป็นความผิด",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การให้ความรู้เรื่องการป้องกันภัยทางเพศมีประโยชน์อย่างไร?",
+ "choices": [
+ "ไม่มีประโยชน์",
+ "เป็นอันตราย",
+ "ช่วยลดความเสี่ยงและรู้สิทธิของตน"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "ผู้พบเห็นการล่วงละเมิดทางเพศเด็กควรทำอย่างไร?",
+ "choices": [
+ "แจ้งเจ้าหน้าที่ทันที",
+ "เพิกเฉย",
+ "ร่วมกระทำด้วย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การบังคับให้ผู้อื่นค้าประเวณีเข้าข่ายความผิดใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "การค้ามนุษย์",
+ "จราจร"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic6",
+ "text": "ความยินยอมที่ได้มาจากการหลอกลวงหรือข่มขู่ ถือว่าอย่างไร?",
+ "choices": [
+ "ใช้ได้ตามปกติ",
+ "ดีที่สุด",
+ "ไม่ถือเป็นความยินยอมที่ชอบด้วยกฎหมาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic6",
+ "text": "การเคารพสิทธิในเนื้อตัวร่างกายของผู้อื่นหมายถึงอะไร?",
+ "choices": [
+ "ไม่แตะต้องหรือล่วงละเมิดโดยไม่ยินยอม",
+ "ทำได้ตามใจ",
+ "บังคับได้"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic6",
+ "text": "สายด่วนแจ้งเหตุค้ามนุษย์หรือช่วยเหลือเด็กในภาวะเสี่ยงคือหมายเลขใด?",
+ "choices": [
+ "1137",
+ "1300 (ศูนย์ช่วยเหลือสังคม)",
+ "1234"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การทำร้ายร่างกายผู้อื่นจนได้รับอันตรายเป็นความผิดฐานใด?",
+ "choices": [
+ "ทำร้ายร่างกาย",
+ "หมิ่นประมาท",
+ "ลักทรัพย์"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การฆ่าผู้อื่นโดยเจตนาเป็นความผิดฐานใด?",
+ "choices": [
+ "กระทำโดยประมาท",
+ "ฆ่าผู้อื่นโดยเจตนา",
+ "ความผิดลหุโทษ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การฆ่าโดยไตร่ตรองไว้ก่อนมีผลต่อโทษอย่างไร?",
+ "choices": [
+ "ได้ลดโทษ",
+ "ไม่มีผล",
+ "โทษหนักขึ้นถึงประหารชีวิต"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การป้องกันตัวพอสมควรแก่เหตุ มีผลทางกฎหมายอย่างไร?",
+ "choices": [
+ "ไม่มีความผิด",
+ "ผิดเสมอ",
+ "ได้รับรางวัล"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การกระทำโดยประมาทเป็นเหตุให้ผู้อื่นถึงแก่ความตายเป็นอย่างไร?",
+ "choices": [
+ "ไม่เป็นความผิด",
+ "มีความผิดฐานประมาท",
+ "เป็นการฆ่าโดยเจตนา"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การข่มขู่ว่าจะทำร้ายผู้อื่นให้เกิดความกลัวเป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "จราจร",
+ "ข่มขืนใจหรือทำให้ตกใจกลัว"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "ผู้ใช้ให้ผู้อื่นกระทำความผิด มีความรับผิดอย่างไร?",
+ "choices": [
+ "รับผิดในฐานะผู้ใช้",
+ "ไม่มีความผิด",
+ "ได้รับรางวัล"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "ผู้สนับสนุนการกระทำความผิดมีความรับผิดหรือไม่?",
+ "choices": [
+ "ไม่มี",
+ "มี ในฐานะผู้สนับสนุน",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การพยายามกระทำความผิดแต่ไม่สำเร็จ มีความรับผิดหรือไม่?",
+ "choices": [
+ "ไม่มีความรับผิด",
+ "โทษเท่าเดิมเสมอ",
+ "มี แต่โทษอาจลดลง"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "ความผิดอาญาส่วนใหญ่ถูกบัญญัติไว้ในกฎหมายใด?",
+ "choices": [
+ "ประมวลกฎหมายอาญา",
+ "กฎหมายแพ่ง",
+ "กฎจราจร"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การครอบครองอาวุธปืนโดยไม่ได้รับอนุญาตเป็นอย่างไร?",
+ "choices": [
+ "ถูกกฎหมาย",
+ "ผิดกฎหมาย",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การมั่วสุมก่อความวุ่นวายในที่สาธารณะเป็นความผิดหรือไม่?",
+ "choices": [
+ "ไม่ผิด",
+ "ควรส่งเสริม",
+ "เป็นความผิด"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "หลัก \"ไม่มีความผิดและไม่มีโทษโดยไม่มีกฎหมาย\" หมายถึงอะไร?",
+ "choices": [
+ "ต้องมีกฎหมายบัญญัติไว้ก่อนจึงลงโทษได้",
+ "ลงโทษได้ตามใจ",
+ "ไม่ต้องมีกฎหมายก็ได้"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การกระทำความผิดซ้ำมีผลอย่างไร?",
+ "choices": [
+ "ได้ลดโทษเสมอ",
+ "อาจถูกเพิ่มโทษ",
+ "ไม่มีผล"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การวางเพลิงเผาทรัพย์ของผู้อื่นเป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "หมิ่นประมาท",
+ "วางเพลิง"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การให้การเท็จต่อศาลในฐานะพยานเป็นความผิดฐานใด?",
+ "choices": [
+ "เบิกความเท็จ",
+ "เรื่องปกติ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "ผู้เสพหรือครอบครองยาเสพติดเข้าสู่กระบวนการใดได้บ้าง?",
+ "choices": [
+ "ไม่มีอะไรเกิดขึ้น",
+ "บำบัดรักษาหรือดำเนินคดีตามกฎหมาย",
+ "ได้รับรางวัล"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic7",
+ "text": "การช่วยผู้กระทำผิดหลบหนีหรือซ่อนหลักฐานเป็นความผิดหรือไม่?",
+ "choices": [
+ "ไม่ผิด",
+ "ควรส่งเสริม",
+ "เป็นความผิด"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic7",
+ "text": "โทษทางอาญาตามประมวลกฎหมายอาญ ามีประเภทใดบ้าง?",
+ "choices": [
+ "ประหารชีวิต จำคุก กักขัง ปรับ ริบทรัพย์สิน",
+ "มีเพียงแบบเดียว",
+ "ไม่มีโทษใด ๆ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic7",
+ "text": "เด็กอายุน้อยที่กระทำผิด กฎหมายปฏิบัติอย่างไร?",
+ "choices": [
+ "ลงโทษเท่าผู้ใหญ่",
+ "ใช้วิธีการสำหรับเด็กเน้นแก้ไขฟื้นฟู",
+ "ไม่ทำอะไรเลย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การหลอกให้โอนเงินผ่านออนไลน์ (แก๊งคอลเซ็นเตอร์) เป็นความผิดฐานใด?",
+ "choices": [
+ "ฉ้อโกงหรือฉ้อโกงประชาชน",
+ "เรื่องปกติ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาตผิดกฎหมายใด?",
+ "choices": [
+ "กฎจราจร",
+ "พ.ร.บ.คอมพิวเตอร์",
+ "กฎหมายแรงงาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การนำข้อมูลเท็จเข้าสู่ระบบจนผู้อื่นเสียหายเป็นอย่างไร?",
+ "choices": [
+ "ถูกกฎหมาย",
+ "ควรส่งเสริม",
+ "ผิด พ.ร.บ.คอมพิวเตอร์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "สิ่งใดที่ \"ไม่ควรทำ\" เพื่อป้องกันบัญชีถูกแฮก?",
+ "choices": [
+ "บอกรหัส OTP ให้ผู้อื่น",
+ "ตั้งรหัสผ่านที่คาดเดายาก",
+ "เปิดการยืนยันสองชั้น (2FA)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "ลิงก์ปลอมที่ขอข้อมูลส่วนตัวหรือรหัสผ่าน เรียกว่ากลโกงแบบใด?",
+ "choices": [
+ "การอัปเดตปกติ",
+ "ฟิชชิ่ง (Phishing)",
+ "โปรโมชั่นจริง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การกลั่นแกล้งรังแกกันบนโลกออนไลน์เรียกว่าอะไร?",
+ "choices": [
+ "อีคอมเมิร์ซ",
+ "อีเลิร์นนิง",
+ "ไซเบอร์บูลลี่ (Cyberbullying)"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "ควรทำอย่างไรเมื่อถูกหลอกโอนเงินออนไลน์?",
+ "choices": [
+ "รีบแจ้งธนาคารและสายด่วน 1441",
+ "เงียบไว้ไม่บอกใคร",
+ "โอนเงินเพิ่ม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การปลอมโปรไฟล์ผู้อื่นเพื่อหลอกลวงเป็นความผิดหรือไม่?",
+ "choices": [
+ "ไม่ผิด",
+ "เป็นความผิด",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "รหัสผ่านที่ปลอดภัยควรเป็นอย่างไร?",
+ "choices": [
+ "ใช้วันเกิดของตน",
+ "ใช้เลข 1234",
+ "ยาว ผสมตัวอักษร ตัวเลข และสัญลักษณ์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การซื้อของออนไลน์ให้ปลอดภัยควรทำอย่างไร?",
+ "choices": [
+ "ตรวจสอบร้านค้าและรีวิวก่อนโอนเงิน",
+ "โอนเงินทันทีทุกครั้ง",
+ "ไม่ต้องตรวจสอบ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การเผยแพร่ภาพหรือข้อมูลส่วนตัวผู้อื่นโดยไม่ยินยอมเป็นอย่างไร?",
+ "choices": [
+ "ถูกต้อง",
+ "อาจผิดกฎหมาย PDPA หรือคอมพิวเตอร์",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "มัลแวร์ (Malware) คืออะไร?",
+ "choices": [
+ "แอปธนาคารทางการ",
+ "เกมการศึกษา",
+ "โปรแกรมประสงค์ร้าย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "สายด่วนแจ้งอายัดบัญชีเมื่อถูกหลอกโอนเงินออนไลน์คือหมายเลขใด?",
+ "choices": [
+ "1441",
+ "1669",
+ "1112"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การติดตั้งแอปจากลิงก์ที่ไม่น่าเชื่อถือเสี่ยงอย่างไร?",
+ "choices": [
+ "ปลอดภัยเสมอ",
+ "ถูกควบคุมเครื่องหรือดูดเงิน",
+ "ได้รับเงิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การพนันออนไลน์ในประเทศไทยเป็นอย่างไร?",
+ "choices": [
+ "ถูกกฎหมาย",
+ "ควรส่งเสริม",
+ "ผิดกฎหมาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การหลอกขายสินค้าออนไลน์แล้วไม่ส่งของเป็นความผิดฐานใด?",
+ "choices": [
+ "ฉ้อโกง",
+ "เรื่องปกติ",
+ "ควรส่งเสริม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "ควรปฏิบัติกับรหัส OTP ที่ได้รับอย่างไร?",
+ "choices": [
+ "แชร์ในกลุ่มเพื่อน",
+ "เก็บเป็นความลับ ไม่บอกใคร",
+ "โพสต์สาธารณะ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การแฮกบัญชีโซเชียลของผู้อื่นเป็นอย่างไร?",
+ "choices": [
+ "เรื่องปกติ",
+ "ควรส่งเสริม",
+ "ผิดกฎหมาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic8",
+ "text": "เมื่อพบข่าวปลอม (Fake News) ควรทำอย่างไร?",
+ "choices": [
+ "ตรวจสอบให้แน่ใจก่อนแชร์",
+ "แชร์ต่อทันที",
+ "สร้างข่าวปลอมเพิ่ม"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic8",
+ "text": "การกรอกข้อมูลบัตรเครดิตในเว็บที่ไม่ปลอดภัยเสี่ยงอย่างไร?",
+ "choices": [
+ "ปลอดภัยเสมอ",
+ "ถูกขโมยเงินหรือข้อมูล",
+ "ได้รับแต้มสะสม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "หน่วยงานใดมีหน้าที่สืบสวนสอบสวนคดีอาญาในเบื้องต้น?",
+ "choices": [
+ "ตำรวจ",
+ "ศาล",
+ "กรมราชทัณฑ์"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ผู้มีหน้าที่ฟ้องคดีอาญาแทนรัฐคือใคร?",
+ "choices": [
+ "ผู้พิพากษา",
+ "พนักงานอัยการ",
+ "ทนายจำเลย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ผู้ทำหน้าที่พิจารณาพิพากษาคดีคือใคร?",
+ "choices": [
+ "ตำรวจ",
+ "อัยการ",
+ "ผู้พิพากษาหรือศาล"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "หลัก \"สันนิษฐานว่าบริสุทธิ์\" หมายถึงอะไร?",
+ "choices": [
+ "ถือว่าบริสุทธิ์จนกว่าศาลจะตัดสินว่าผิด",
+ "ถือว่าผิดทันที",
+ "ไม่ต้องพิสูจน์"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ผู้ต้องหามีสิทธิข้อใดในชั้นสอบสวน?",
+ "choices": [
+ "ต้องรับสารภาพ",
+ "มีสิทธิมีทนายและจะไม่ให้การก็ได้",
+ "ห้ามนิ่งเงียบ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "การปล่อยตัวชั่วคราวระหว่างดำเนินคดีเรียกว่าอะไร?",
+ "choices": [
+ "การจำคุก",
+ "การปรับ",
+ "การประกันตัว"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ศาลชั้นต้น อุทธรณ์ และฎีกา เป็นลำดับของอะไร?",
+ "choices": [
+ "ลำดับชั้นของศาล",
+ "ชื่อเรือนจำ",
+ "ชื่อสถานีตำรวจ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "หากไม่พอใจคำพิพากษาศาลชั้นต้น คู่ความทำสิ่งใดได้?",
+ "choices": [
+ "แก้แค้นด้วยตนเอง",
+ "ยื่นอุทธรณ์",
+ "เพิกเฉยเสมอ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ผู้ไม่มีเงินจ้างทนายในคดีอาญา มีสิทธิอย่างไร?",
+ "choices": [
+ "ต้องสู้คดีด้วยตนเอง",
+ "ถูกตัดสินทันที",
+ "ขอทนายความที่รัฐจัดให้"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "หน่วยงานใดดูแลผู้ต้องขังหลังศาลพิพากษาจำคุก?",
+ "choices": [
+ "กรมราชทัณฑ์",
+ "อัยการ",
+ "ตำรวจจราจร"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "คดีแพ่งต่างจากคดีอาญาอย่างไร?",
+ "choices": [
+ "เหมือนกันทุกอย่าง",
+ "แพ่งเป็นข้อพิพาทเรื่องสิทธิ/หนี้ อาญาเป็นการลงโทษผู้กระทำผิด",
+ "ไม่มีความแตกต่าง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "พยานมีหน้าที่อย่างไรต่อศาล?",
+ "choices": [
+ "โกหกได้",
+ "ไม่ต้องมาศาล",
+ "ให้การตามความจริง"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "การไกล่เกลี่ยข้อพิพาทมีประโยชน์อย่างไร?",
+ "choices": [
+ "ยุติข้อพิพาทได้เร็วและประหยัด",
+ "เพิ่มความขัดแย้ง",
+ "ผิดกฎหมาย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "หมายค้นหรือหมายจับออกโดยใคร?",
+ "choices": [
+ "เพื่อนบ้าน",
+ "ศาล",
+ "ห้างสรรพสินค้า"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "สิทธิของผู้เสียหายในคดีอาญาข้อใดถูกต้อง?",
+ "choices": [
+ "ห้ามยุ่งเกี่ยว",
+ "ต้องนิ่งเงียบ",
+ "ร้องทุกข์และเป็นโจทก์ร่วมได้"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ทนายความมีบทบาทอย่างไร?",
+ "choices": [
+ "ให้คำปรึกษาและว่าความแทนคู่ความ",
+ "ตัดสินคดี",
+ "จับกุมผู้ต้องหา"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "การบังคับคดีแพ่งทำเพื่อสิ่งใด?",
+ "choices": [
+ "ลงโทษจำคุก",
+ "ให้เป็นไปตามคำพิพากษา เช่น ยึดทรัพย์ชำระหนี้",
+ "ไม่มีผลใด ๆ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic9",
+ "text": "ระบบยุติธรรมสำหรับเด็กและเยาวชนเน้นสิ่งใด?",
+ "choices": [
+ "การลงโทษที่รุนแรงที่สุด",
+ "ไม่สนใจ",
+ "การแก้ไขฟื้นฟูมากกว่าการลงโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic9",
+ "text": "การอุทธรณ์หรือฎีกาต้องทำภายในกำหนดเวลาหรือไม่?",
+ "choices": [
+ "ต้องทำภายในระยะเวลาที่กฎหมายกำหนด",
+ "ไม่มีกำหนดเวลา",
+ "ทำเมื่อใดก็ได้"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic9",
+ "text": "กระบวนการยุติธรรมเชิงสมานฉันท์มีเป้าหมายอย่างไร?",
+ "choices": [
+ "เพิ่มโทษให้หนัก",
+ "เยียวยาและคืนความสัมพันธ์ระหว่างคู่กรณี",
+ "ส่งเสริมการแก้แค้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กองทุนยุติธรรมช่วยเหลือประชาชนในเรื่องใด?",
+ "choices": [
+ "ค่าใช้จ่ายในการเข้าถึงความยุติธรรม เช่น ค่าทนาย/ประกันตัว",
+ "ค่าท่องเที่ยว",
+ "ค่าอาหารประจำวัน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "สายด่วนกระทรวงยุติธรรมคือหมายเลขใด?",
+ "choices": [
+ "1669",
+ "1111 กด 77",
+ "199"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กรมคุ้มครองสิทธิและเสรีภาพมีหน้าที่หลักด้านใด?",
+ "choices": [
+ "เก็บภาษี",
+ "ออกใบขับขี่",
+ "คุ้มครองสิทธิและช่วยเหลือเยียวยาผู้เสียหาย"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "ผู้เสียหายในคดีอาญาที่ไม่ได้มีส่วนผิด อาจได้รับสิ่งใดจากรัฐ?",
+ "choices": [
+ "เงินช่วยเหลือหรือค่าตอบแทนผู้เสียหาย",
+ "ค่าปรับ",
+ "ไม่ได้รับสิ่งใด"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กรมบังคับคดีมีหน้าที่อะไร?",
+ "choices": [
+ "สอนหนังสือ",
+ "บังคับคดีตามคำพิพากษา เช่น ยึดและขายทอดตลาด",
+ "รักษาพยาบาล"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "ศูนย์ยุติธรรมชุมชนมีไว้เพื่อสิ่งใด?",
+ "choices": [
+ "ขายสินค้า",
+ "จัดคอนเสิร์ต",
+ "ให้บริการและไกล่เกลี่ยในระดับชุมชน"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การไกล่เกลี่ยข้อพิพาทโดยหน่วยงานรัฐมีค่าใช้จ่ายอย่างไร?",
+ "choices": [
+ "ส่วนใหญ่ไม่มีค่าใช้จ่ายหรือค่าใช้จ่ายต่ำ",
+ "แพงมาก",
+ "ต้องติดสินบน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กรมพินิจและคุ้มครองเด็กและเยาวชนดูแลเรื่องใด?",
+ "choices": [
+ "ผู้สูงอายุทั่วไป",
+ "เด็กและเยาวชนที่เข้าสู่กระบวนการยุติธรรม",
+ "การจัดเก็บภาษี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "สถาบันนิติวิทยาศาสตร์มีบทบาทอย่างไร?",
+ "choices": [
+ "ออกใบขับขี่",
+ "เก็บภาษี",
+ "ตรวจพิสูจน์หลักฐานทางวิทยาศาสตร์"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "ประชาชนขอคำปรึกษากฎหมายเบื้องต้นจากกระทรวงยุติธรรมได้หรือไม่?",
+ "choices": [
+ "ได้ มีบริการให้คำปรึกษา",
+ "ไม่ได้",
+ "ต้องจ่ายเงินจำนวนมาก"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กรมสอบสวนคดีพิเศษ (DSI) ดูแลคดีประเภทใด?",
+ "choices": [
+ "คดีจราจรเล็กน้อย",
+ "คดีพิเศษที่ซับซ้อนหรือกระทบส่วนรวม",
+ "คดีทั่วไปในชุมชน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การช่วยเหลือทางการเงินแก่ผู้ต้องหายากจนเพื่อประกันตัว มาจากแหล่งใด?",
+ "choices": [
+ "เงินส่วนตัวของศาล",
+ "ภาษีท้องถิ่น",
+ "กองทุนยุติธรรม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การคุ้มครองพยานในคดีสำคัญเป็นภารกิจด้านใด?",
+ "choices": [
+ "งานคุ้มครองพยาน",
+ "งานออกใบขับขี่",
+ "งานเก็บขยะ"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "หากประชาชนไม่ได้รับความเป็นธรรม ร้องเรียนได้ที่ใดบ้าง?",
+ "choices": [
+ "ร้านสะดวกซื้อ",
+ "ศูนย์บริการหรือสายด่วนกระทรวงยุติธรรม",
+ "ธนาคาร"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การฟื้นฟูสมรรถภาพผู้ติดยาเสพติดเกี่ยวข้องกับหน่วยงานด้านใด?",
+ "choices": [
+ "กรมที่ดิน",
+ "ไปรษณีย์",
+ "หน่วยงานด้านยาเสพติดและสาธารณสุข"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "บริการ \"ทนายความอาสา\" มีไว้เพื่อสิ่งใด?",
+ "choices": [
+ "ช่วยเหลือผู้ขาดแคลนทุนทรัพย์",
+ "เก็บค่าธรรมเนียมราคาแพง",
+ "โฆษณาสินค้า"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การขอรับค่าตอบแทนผู้เสียหายต้องยื่นภายในกำหนดเวลาหรือไม่?",
+ "choices": [
+ "ไม่มีกำหนดเวลา",
+ "ต้องยื่นภายในระยะเวลาที่กฎหมายกำหนด",
+ "ยื่นเมื่อใดก็ได้"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "categoryId": "topic10",
+ "text": "กระทรวงยุติธรรมส่งเสริมความรู้กฎหมายแก่ประชาชนเพื่ออะไร?",
+ "choices": [
+ "เพิ่มจำนวนคดี",
+ "จัดเก็บภาษี",
+ "ให้ประชาชนรู้สิทธิและเข้าถึงความยุติธรรม"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "categoryId": "topic10",
+ "text": "แนวคิด \"ยุติธรรมสร้างสุข\" เกี่ยวกับสิ่งใด?",
+ "choices": [
+ "บริการประชาชนให้เข้าถึงความยุติธรรมอย่างทั่วถึง",
+ "การท่องเที่ยว",
+ "การกีฬา"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "categoryId": "topic10",
+ "text": "การตรวจสอบประวัติอาชญากรรมเพื่อสมัครงานเกี่ยวข้องกับหน่วยงานใด?",
+ "choices": [
+ "การไฟฟ้า",
+ "หน่วยงานตำรวจที่เกี่ยวข้อง",
+ "ไปรษณีย์"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialQuizSets": {
+ "L1_C1": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
+ "choices": [
+ "พ.ร.บ.จราจร",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์",
+ "กฎหมายแรงงาน",
+ "กฎหมายที่ดิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานเอกสารอิเล็กทรอนิกส์",
+ "พยานแวดล้อม",
+ "ไม่ถือเป็นหลักฐาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การหลอกให้เหยื่อกรอกข้อมูล/โอนเงินผ่านข้อความหรือเว็บปลอม เรียกว่าอะไร?",
+ "choices": [
+ "ฟิชชิง (Phishing)",
+ "ดีบัก (Debug)",
+ "แบ็กอัป (Backup)",
+ "ฟอร์แมต (Format)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ทำหน้าที่พิจารณาพิพากษาคดีในศาลคือใคร?",
+ "choices": [
+ "ทนายความ",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ตำรวจ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ถูกกล่าวหาว่ากระทำผิดและถูกฟ้องต่อศาลเรียกว่า?",
+ "choices": [
+ "โจทก์",
+ "จำเลย",
+ "พยาน",
+ "ผู้เสียหาย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เอกสารที่ศาลออกเพื่ออนุญาตให้จับกุมบุคคลเรียกว่า?",
+ "choices": [
+ "หมายค้น",
+ "หมายจับ",
+ "หมายเรียก",
+ "หมายนัด"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ว่าความและให้คำปรึกษากฎหมายแก่คู่ความคือ?",
+ "choices": [
+ "ผู้พิพากษา",
+ "ทนายความ",
+ "อัยการ",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "สถานที่ที่ใช้พิจารณาคดีเรียกว่าอะไร?",
+ "choices": [
+ "สถานีตำรวจ",
+ "ศาล",
+ "เรือนจำ",
+ "ที่ว่าการอำเภอ"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 1,
+ "th": "เพิ่มเวลาเล่นมินิเกมทันที (+10 วินาที)",
+ "en": "Time Dilation",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-1.png",
+ "txtImageUrl": "/Game/img/special-cards/card-1-txt.png"
+ }
+ },
+ "L1_C2": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญ เป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ชิงทรัพย์/ปล้นทรัพย์",
+ "ยักยอกทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ลายนิ้วมือที่พบในที่เกิดเหตุ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานวัตถุ",
+ "พยานเอกสาร",
+ "คำรับสารภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การปล้นทรัพย์ที่ร่วมกันตั้งแต่สามคนขึ้นไป กฎหมายกำหนดโทษอย่างไร?",
+ "choices": [
+ "เบากว่าปกติ",
+ "เท่าเดิม",
+ "หนักขึ้น",
+ "ยกเว้นโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ทำหน้าที่พิจารณาพิพากษาคดีในศาลคือใคร?",
+ "choices": [
+ "ทนายความ",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ตำรวจ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ถูกกล่าวหาว่ากระทำผิดและถูกฟ้องต่อศาลเรียกว่า?",
+ "choices": [
+ "โจทก์",
+ "จำเลย",
+ "พยาน",
+ "ผู้เสียหาย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เอกสารที่ศาลออกเพื่ออนุญาตให้จับกุมบุคคลเรียกว่า?",
+ "choices": [
+ "หมายค้น",
+ "หมายจับ",
+ "หมายเรียก",
+ "หมายนัด"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ว่าความและให้คำปรึกษากฎหมายแก่คู่ความคือ?",
+ "choices": [
+ "ผู้พิพากษา",
+ "ทนายความ",
+ "อัยการ",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "สถานที่ที่ใช้พิจารณาคดีเรียกว่าอะไร?",
+ "choices": [
+ "สถานีตำรวจ",
+ "ศาล",
+ "เรือนจำ",
+ "ที่ว่าการอำเภอ"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 2,
+ "th": "เพิ่มเวลาในห้องพิจารณาคดี",
+ "en": "Extension",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-2.png",
+ "txtImageUrl": "/Game/img/special-cards/card-2-txt.png"
+ }
+ },
+ "L1_C3": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "ผู้ที่นำคดีอาญาขึ้นฟ้องต่อศาลแทนรัฐ เรียกว่าอะไร?",
+ "choices": [
+ "ทนายจำเลย",
+ "พนักงานอัยการ",
+ "ผู้พิพากษา",
+ "เจ้าพนักงานบังคับคดี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการ 'สันนิษฐานว่าบริสุทธิ์ไว้ก่อน' หมายความว่าอย่างไร?",
+ "choices": [
+ "จำเลยผิดจนกว่าจะพิสูจน์ได้",
+ "จำเลยบริสุทธิ์จนกว่าจะพิสูจน์ว่าผิด",
+ "ศาลตัดสินทันที",
+ "ไม่ต้องมีพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การอุทธรณ์ หมายถึงอะไร?",
+ "choices": [
+ "การยอมรับโทษ",
+ "การขอให้ศาลสูงพิจารณาคดีใหม่",
+ "การถอนฟ้อง",
+ "การประนีประนอม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ทำหน้าที่พิจารณาพิพากษาคดีในศาลคือใคร?",
+ "choices": [
+ "ทนายความ",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ตำรวจ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ถูกกล่าวหาว่ากระทำผิดและถูกฟ้องต่อศาลเรียกว่า?",
+ "choices": [
+ "โจทก์",
+ "จำเลย",
+ "พยาน",
+ "ผู้เสียหาย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เอกสารที่ศาลออกเพื่ออนุญาตให้จับกุมบุคคลเรียกว่า?",
+ "choices": [
+ "หมายค้น",
+ "หมายจับ",
+ "หมายเรียก",
+ "หมายนัด"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ผู้ที่ว่าความและให้คำปรึกษากฎหมายแก่คู่ความคือ?",
+ "choices": [
+ "ผู้พิพากษา",
+ "ทนายความ",
+ "อัยการ",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "สถานที่ที่ใช้พิจารณาคดีเรียกว่าอะไร?",
+ "choices": [
+ "สถานีตำรวจ",
+ "ศาล",
+ "เรือนจำ",
+ "ที่ว่าการอำเภอ"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 3,
+ "th": "ห้ามเล่นมินิเกม 1 ตา",
+ "en": "Ban Card",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-3.png",
+ "txtImageUrl": "/Game/img/special-cards/card-3-txt.png"
+ }
+ },
+ "L2_C1": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
+ "choices": [
+ "พ.ร.บ.จราจร",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์",
+ "กฎหมายแรงงาน",
+ "กฎหมายที่ดิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานเอกสารอิเล็กทรอนิกส์",
+ "พยานแวดล้อม",
+ "ไม่ถือเป็นหลักฐาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การหลอกให้เหยื่อกรอกข้อมูล/โอนเงินผ่านข้อความหรือเว็บปลอม เรียกว่าอะไร?",
+ "choices": [
+ "ฟิชชิง (Phishing)",
+ "ดีบัก (Debug)",
+ "แบ็กอัป (Backup)",
+ "ฟอร์แมต (Format)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ได้รับความเสียหายจากการกระทำผิดเรียกว่า?",
+ "choices": [
+ "จำเลย",
+ "ผู้เสียหาย",
+ "พยาน",
+ "อัยการ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การให้การโดยบุคคลที่รู้เห็นเหตุการณ์ คือพยานประเภทใด?",
+ "choices": [
+ "พยานวัตถุ",
+ "พยานบุคคล",
+ "พยานเอกสาร",
+ "พยานแวดล้อม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หมายที่ศาลออกเพื่อให้เข้าตรวจค้นสถานที่เรียกว่า?",
+ "choices": [
+ "หมายจับ",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายขัง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การที่ผู้ต้องหายอมรับว่าได้กระทำผิดจริงเรียกว่า?",
+ "choices": [
+ "การปฏิเสธ",
+ "คำรับสารภาพ",
+ "การอุทธรณ์",
+ "การไกล่เกลี่ย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เงินหรือหลักทรัพย์ที่วางเพื่อขอปล่อยตัวชั่วคราวเรียกว่า?",
+ "choices": [
+ "ค่าปรับ",
+ "หลักประกัน (ประกันตัว)",
+ "ค่าธรรมเนียมศาล",
+ "สินบน"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 4,
+ "th": "การ์ดปิดปาก",
+ "en": "Silence Card",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-4.png",
+ "txtImageUrl": "/Game/img/special-cards/card-4-txt.png"
+ }
+ },
+ "L2_C2": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญ เป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ชิงทรัพย์/ปล้นทรัพย์",
+ "ยักยอกทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ลายนิ้วมือที่พบในที่เกิดเหตุ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานวัตถุ",
+ "พยานเอกสาร",
+ "คำรับสารภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การปล้นทรัพย์ที่ร่วมกันตั้งแต่สามคนขึ้นไป กฎหมายกำหนดโทษอย่างไร?",
+ "choices": [
+ "เบากว่าปกติ",
+ "เท่าเดิม",
+ "หนักขึ้น",
+ "ยกเว้นโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ได้รับความเสียหายจากการกระทำผิดเรียกว่า?",
+ "choices": [
+ "จำเลย",
+ "ผู้เสียหาย",
+ "พยาน",
+ "อัยการ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การให้การโดยบุคคลที่รู้เห็นเหตุการณ์ คือพยานประเภทใด?",
+ "choices": [
+ "พยานวัตถุ",
+ "พยานบุคคล",
+ "พยานเอกสาร",
+ "พยานแวดล้อม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หมายที่ศาลออกเพื่อให้เข้าตรวจค้นสถานที่เรียกว่า?",
+ "choices": [
+ "หมายจับ",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายขัง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การที่ผู้ต้องหายอมรับว่าได้กระทำผิดจริงเรียกว่า?",
+ "choices": [
+ "การปฏิเสธ",
+ "คำรับสารภาพ",
+ "การอุทธรณ์",
+ "การไกล่เกลี่ย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เงินหรือหลักทรัพย์ที่วางเพื่อขอปล่อยตัวชั่วคราวเรียกว่า?",
+ "choices": [
+ "ค่าปรับ",
+ "หลักประกัน (ประกันตัว)",
+ "ค่าธรรมเนียมศาล",
+ "สินบน"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 5,
+ "th": "เหรียญประกันตัว",
+ "en": "Bail Coin",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-5.png",
+ "txtImageUrl": "/Game/img/special-cards/card-5-txt.png"
+ }
+ },
+ "L2_C3": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "ผู้ที่นำคดีอาญาขึ้นฟ้องต่อศาลแทนรัฐ เรียกว่าอะไร?",
+ "choices": [
+ "ทนายจำเลย",
+ "พนักงานอัยการ",
+ "ผู้พิพากษา",
+ "เจ้าพนักงานบังคับคดี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการ 'สันนิษฐานว่าบริสุทธิ์ไว้ก่อน' หมายความว่าอย่างไร?",
+ "choices": [
+ "จำเลยผิดจนกว่าจะพิสูจน์ได้",
+ "จำเลยบริสุทธิ์จนกว่าจะพิสูจน์ว่าผิด",
+ "ศาลตัดสินทันที",
+ "ไม่ต้องมีพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การอุทธรณ์ หมายถึงอะไร?",
+ "choices": [
+ "การยอมรับโทษ",
+ "การขอให้ศาลสูงพิจารณาคดีใหม่",
+ "การถอนฟ้อง",
+ "การประนีประนอม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "บุคคลที่ได้รับความเสียหายจากการกระทำผิดเรียกว่า?",
+ "choices": [
+ "จำเลย",
+ "ผู้เสียหาย",
+ "พยาน",
+ "อัยการ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การให้การโดยบุคคลที่รู้เห็นเหตุการณ์ คือพยานประเภทใด?",
+ "choices": [
+ "พยานวัตถุ",
+ "พยานบุคคล",
+ "พยานเอกสาร",
+ "พยานแวดล้อม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หมายที่ศาลออกเพื่อให้เข้าตรวจค้นสถานที่เรียกว่า?",
+ "choices": [
+ "หมายจับ",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายขัง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การที่ผู้ต้องหายอมรับว่าได้กระทำผิดจริงเรียกว่า?",
+ "choices": [
+ "การปฏิเสธ",
+ "คำรับสารภาพ",
+ "การอุทธรณ์",
+ "การไกล่เกลี่ย"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เงินหรือหลักทรัพย์ที่วางเพื่อขอปล่อยตัวชั่วคราวเรียกว่า?",
+ "choices": [
+ "ค่าปรับ",
+ "หลักประกัน (ประกันตัว)",
+ "ค่าธรรมเนียมศาล",
+ "สินบน"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 6,
+ "th": "รับ coin เพิ่ม",
+ "en": "Fund",
+ "rarity": "common",
+ "imageUrl": "/Game/img/special-cards/card-6.png",
+ "txtImageUrl": "/Game/img/special-cards/card-6-txt.png"
+ }
+ },
+ "L3_C1": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
+ "choices": [
+ "พ.ร.บ.จราจร",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์",
+ "กฎหมายแรงงาน",
+ "กฎหมายที่ดิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานเอกสารอิเล็กทรอนิกส์",
+ "พยานแวดล้อม",
+ "ไม่ถือเป็นหลักฐาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การหลอกให้เหยื่อกรอกข้อมูล/โอนเงินผ่านข้อความหรือเว็บปลอม เรียกว่าอะไร?",
+ "choices": [
+ "ฟิชชิง (Phishing)",
+ "ดีบัก (Debug)",
+ "แบ็กอัป (Backup)",
+ "ฟอร์แมต (Format)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การขอให้ศาลสูงพิจารณาคดีใหม่หลังไม่พอใจคำพิพากษาเรียกว่า?",
+ "choices": [
+ "การฟ้องร้อง",
+ "การอุทธรณ์/ฎีกา",
+ "การไกล่เกลี่ย",
+ "การสืบพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักฐานที่ไม่ได้ชี้โดยตรงแต่ทำให้อนุมานข้อเท็จจริงได้เรียกว่า?",
+ "choices": [
+ "พยานแวดล้อม",
+ "พยานบุคคล",
+ "คำรับสารภาพ",
+ "พยานเอกสาร"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ระยะเวลาที่กฎหมายกำหนดให้ฟ้องคดีได้เรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "วันนัด",
+ "กำหนดโทษ",
+ "ทัณฑ์บน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำที่ครบองค์ประกอบความผิดตามกฎหมายอาญาเรียกว่า?",
+ "choices": [
+ "การละเมิด",
+ "ความผิดอาญา",
+ "การผิดสัญญา",
+ "การละเว้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เจ้าหน้าที่ที่ทำหน้าที่สืบสวนสอบสวนคดีอาญาคือ?",
+ "choices": [
+ "พนักงานสอบสวน",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ทนายความ"
+ ],
+ "correctIndex": 0
+ }
+ ],
+ "specialCard": {
+ "cardId": 7,
+ "th": "รับหลักฐานฟรี",
+ "en": "Free Evidence",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-7.png",
+ "txtImageUrl": "/Game/img/special-cards/card--7txt.png"
+ }
+ },
+ "L3_C2": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญ เป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ชิงทรัพย์/ปล้นทรัพย์",
+ "ยักยอกทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ลายนิ้วมือที่พบในที่เกิดเหตุ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานวัตถุ",
+ "พยานเอกสาร",
+ "คำรับสารภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การปล้นทรัพย์ที่ร่วมกันตั้งแต่สามคนขึ้นไป กฎหมายกำหนดโทษอย่างไร?",
+ "choices": [
+ "เบากว่าปกติ",
+ "เท่าเดิม",
+ "หนักขึ้น",
+ "ยกเว้นโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "type": "mcq",
+ "text": "การขอให้ศาลสูงพิจารณาคดีใหม่หลังไม่พอใจคำพิพากษาเรียกว่า?",
+ "choices": [
+ "การฟ้องร้อง",
+ "การอุทธรณ์/ฎีกา",
+ "การไกล่เกลี่ย",
+ "การสืบพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักฐานที่ไม่ได้ชี้โดยตรงแต่ทำให้อนุมานข้อเท็จจริงได้เรียกว่า?",
+ "choices": [
+ "พยานแวดล้อม",
+ "พยานบุคคล",
+ "คำรับสารภาพ",
+ "พยานเอกสาร"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ระยะเวลาที่กฎหมายกำหนดให้ฟ้องคดีได้เรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "วันนัด",
+ "กำหนดโทษ",
+ "ทัณฑ์บน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำที่ครบองค์ประกอบความผิดตามกฎหมายอาญาเรียกว่า?",
+ "choices": [
+ "การละเมิด",
+ "ความผิดอาญา",
+ "การผิดสัญญา",
+ "การละเว้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เจ้าหน้าที่ที่ทำหน้าที่สืบสวนสอบสวนคดีอาญาคือ?",
+ "choices": [
+ "พนักงานสอบสวน",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ทนายความ"
+ ],
+ "correctIndex": 0
+ }
+ ],
+ "specialCard": {
+ "cardId": 1,
+ "th": "เพิ่มเวลาเล่นมินิเกมทันที (+10 วินาที)",
+ "en": "Time Dilation",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-1.png",
+ "txtImageUrl": "/Game/img/special-cards/card-1-txt.png"
+ }
+ },
+ "L3_C3": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "ผู้ที่นำคดีอาญาขึ้นฟ้องต่อศาลแทนรัฐ เรียกว่าอะไร?",
+ "choices": [
+ "ทนายจำเลย",
+ "พนักงานอัยการ",
+ "ผู้พิพากษา",
+ "เจ้าพนักงานบังคับคดี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการ 'สันนิษฐานว่าบริสุทธิ์ไว้ก่อน' หมายความว่าอย่างไร?",
+ "choices": [
+ "จำเลยผิดจนกว่าจะพิสูจน์ได้",
+ "จำเลยบริสุทธิ์จนกว่าจะพิสูจน์ว่าผิด",
+ "ศาลตัดสินทันที",
+ "ไม่ต้องมีพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การอุทธรณ์ หมายถึงอะไร?",
+ "choices": [
+ "การยอมรับโทษ",
+ "การขอให้ศาลสูงพิจารณาคดีใหม่",
+ "การถอนฟ้อง",
+ "การประนีประนอม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การขอให้ศาลสูงพิจารณาคดีใหม่หลังไม่พอใจคำพิพากษาเรียกว่า?",
+ "choices": [
+ "การฟ้องร้อง",
+ "การอุทธรณ์/ฎีกา",
+ "การไกล่เกลี่ย",
+ "การสืบพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักฐานที่ไม่ได้ชี้โดยตรงแต่ทำให้อนุมานข้อเท็จจริงได้เรียกว่า?",
+ "choices": [
+ "พยานแวดล้อม",
+ "พยานบุคคล",
+ "คำรับสารภาพ",
+ "พยานเอกสาร"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ระยะเวลาที่กฎหมายกำหนดให้ฟ้องคดีได้เรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "วันนัด",
+ "กำหนดโทษ",
+ "ทัณฑ์บน"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำที่ครบองค์ประกอบความผิดตามกฎหมายอาญาเรียกว่า?",
+ "choices": [
+ "การละเมิด",
+ "ความผิดอาญา",
+ "การผิดสัญญา",
+ "การละเว้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "เจ้าหน้าที่ที่ทำหน้าที่สืบสวนสอบสวนคดีอาญาคือ?",
+ "choices": [
+ "พนักงานสอบสวน",
+ "ผู้พิพากษา",
+ "เสมียนศาล",
+ "ทนายความ"
+ ],
+ "correctIndex": 0
+ }
+ ],
+ "specialCard": {
+ "cardId": 2,
+ "th": "เพิ่มเวลาในห้องพิจารณาคดี",
+ "en": "Extension",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-2.png",
+ "txtImageUrl": "/Game/img/special-cards/card-2-txt.png"
+ }
+ },
+ "L4_C1": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
+ "choices": [
+ "พ.ร.บ.จราจร",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์",
+ "กฎหมายแรงงาน",
+ "กฎหมายที่ดิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานเอกสารอิเล็กทรอนิกส์",
+ "พยานแวดล้อม",
+ "ไม่ถือเป็นหลักฐาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การหลอกให้เหยื่อกรอกข้อมูล/โอนเงินผ่านข้อความหรือเว็บปลอม เรียกว่าอะไร?",
+ "choices": [
+ "ฟิชชิง (Phishing)",
+ "ดีบัก (Debug)",
+ "แบ็กอัป (Backup)",
+ "ฟอร์แมต (Format)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการที่ห้ามนำบุคคลมาฟ้องซ้ำในความผิดเดียวกันเรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "หลักห้ามฟ้องซ้ำ",
+ "หลักสันนิษฐานบริสุทธิ์",
+ "ภาระการพิสูจน์"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ภาระการพิสูจน์ในคดีอาญาตกอยู่ที่ฝ่ายใด?",
+ "choices": [
+ "จำเลย",
+ "โจทก์/พนักงานอัยการ",
+ "ศาล",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำผิดโดยไม่มีเจตนาแต่ขาดความระมัดระวังเรียกว่า?",
+ "choices": [
+ "เจตนา",
+ "ประมาท",
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "คำสั่งศาลให้คุมขังผู้ต้องหาระหว่างพิจารณาเรียกว่า?",
+ "choices": [
+ "หมายขัง",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายปล่อย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การยุติข้อพิพาทโดยมีคนกลางช่วยให้ตกลงกันเรียกว่า?",
+ "choices": [
+ "การพิพากษา",
+ "การไกล่เกลี่ย",
+ "การอุทธรณ์",
+ "การบังคับคดี"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 3,
+ "th": "ห้ามเล่นมินิเกม 1 ตา",
+ "en": "Ban Card",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-3.png",
+ "txtImageUrl": "/Game/img/special-cards/card-3-txt.png"
+ }
+ },
+ "L4_C2": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญ เป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ชิงทรัพย์/ปล้นทรัพย์",
+ "ยักยอกทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ลายนิ้วมือที่พบในที่เกิดเหตุ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานวัตถุ",
+ "พยานเอกสาร",
+ "คำรับสารภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การปล้นทรัพย์ที่ร่วมกันตั้งแต่สามคนขึ้นไป กฎหมายกำหนดโทษอย่างไร?",
+ "choices": [
+ "เบากว่าปกติ",
+ "เท่าเดิม",
+ "หนักขึ้น",
+ "ยกเว้นโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการที่ห้ามนำบุคคลมาฟ้องซ้ำในความผิดเดียวกันเรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "หลักห้ามฟ้องซ้ำ",
+ "หลักสันนิษฐานบริสุทธิ์",
+ "ภาระการพิสูจน์"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ภาระการพิสูจน์ในคดีอาญาตกอยู่ที่ฝ่ายใด?",
+ "choices": [
+ "จำเลย",
+ "โจทก์/พนักงานอัยการ",
+ "ศาล",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำผิดโดยไม่มีเจตนาแต่ขาดความระมัดระวังเรียกว่า?",
+ "choices": [
+ "เจตนา",
+ "ประมาท",
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "คำสั่งศาลให้คุมขังผู้ต้องหาระหว่างพิจารณาเรียกว่า?",
+ "choices": [
+ "หมายขัง",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายปล่อย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การยุติข้อพิพาทโดยมีคนกลางช่วยให้ตกลงกันเรียกว่า?",
+ "choices": [
+ "การพิพากษา",
+ "การไกล่เกลี่ย",
+ "การอุทธรณ์",
+ "การบังคับคดี"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 4,
+ "th": "การ์ดปิดปาก",
+ "en": "Silence Card",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-4.png",
+ "txtImageUrl": "/Game/img/special-cards/card-4-txt.png"
+ }
+ },
+ "L4_C3": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "ผู้ที่นำคดีอาญาขึ้นฟ้องต่อศาลแทนรัฐ เรียกว่าอะไร?",
+ "choices": [
+ "ทนายจำเลย",
+ "พนักงานอัยการ",
+ "ผู้พิพากษา",
+ "เจ้าพนักงานบังคับคดี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการ 'สันนิษฐานว่าบริสุทธิ์ไว้ก่อน' หมายความว่าอย่างไร?",
+ "choices": [
+ "จำเลยผิดจนกว่าจะพิสูจน์ได้",
+ "จำเลยบริสุทธิ์จนกว่าจะพิสูจน์ว่าผิด",
+ "ศาลตัดสินทันที",
+ "ไม่ต้องมีพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การอุทธรณ์ หมายถึงอะไร?",
+ "choices": [
+ "การยอมรับโทษ",
+ "การขอให้ศาลสูงพิจารณาคดีใหม่",
+ "การถอนฟ้อง",
+ "การประนีประนอม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการที่ห้ามนำบุคคลมาฟ้องซ้ำในความผิดเดียวกันเรียกว่า?",
+ "choices": [
+ "อายุความ",
+ "หลักห้ามฟ้องซ้ำ",
+ "หลักสันนิษฐานบริสุทธิ์",
+ "ภาระการพิสูจน์"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ภาระการพิสูจน์ในคดีอาญาตกอยู่ที่ฝ่ายใด?",
+ "choices": [
+ "จำเลย",
+ "โจทก์/พนักงานอัยการ",
+ "ศาล",
+ "พยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำผิดโดยไม่มีเจตนาแต่ขาดความระมัดระวังเรียกว่า?",
+ "choices": [
+ "เจตนา",
+ "ประมาท",
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "คำสั่งศาลให้คุมขังผู้ต้องหาระหว่างพิจารณาเรียกว่า?",
+ "choices": [
+ "หมายขัง",
+ "หมายค้น",
+ "หมายเรียก",
+ "หมายปล่อย"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "การยุติข้อพิพาทโดยมีคนกลางช่วยให้ตกลงกันเรียกว่า?",
+ "choices": [
+ "การพิพากษา",
+ "การไกล่เกลี่ย",
+ "การอุทธรณ์",
+ "การบังคับคดี"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 5,
+ "th": "เหรียญประกันตัว",
+ "en": "Bail Coin",
+ "rarity": "rare",
+ "imageUrl": "/Game/img/special-cards/card-5.png",
+ "txtImageUrl": "/Game/img/special-cards/card-5-txt.png"
+ }
+ },
+ "L5_C1": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
+ "choices": [
+ "พ.ร.บ.จราจร",
+ "พ.ร.บ.ว่าด้วยการกระทำผิดเกี่ยวกับคอมพิวเตอร์",
+ "กฎหมายแรงงาน",
+ "กฎหมายที่ดิน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานเอกสารอิเล็กทรอนิกส์",
+ "พยานแวดล้อม",
+ "ไม่ถือเป็นหลักฐาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การหลอกให้เหยื่อกรอกข้อมูล/โอนเงินผ่านข้อความหรือเว็บปลอม เรียกว่าอะไร?",
+ "choices": [
+ "ฟิชชิง (Phishing)",
+ "ดีบัก (Debug)",
+ "แบ็กอัป (Backup)",
+ "ฟอร์แมต (Format)"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "พยานหลักฐานที่ได้มาโดยมิชอบด้วยกฎหมาย โดยหลักแล้วศาลจะ?",
+ "choices": [
+ "รับฟังได้เสมอ",
+ "อาจรับฟังไม่ได้ (ต้องห้าม)",
+ "ใช้ลงโทษได้ทันที",
+ "ส่งคืนเท่านั้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลัก 'ยกประโยชน์แห่งความสงสัยให้จำเลย' ใช้เมื่อใด?",
+ "choices": [
+ "เมื่อพยานชัดเจน",
+ "เมื่อมีข้อสงสัยตามสมควร",
+ "เมื่อจำเลยรับสารภาพ",
+ "เมื่ออัยการร้องขอ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การร่วมกระทำผิดโดยแบ่งหน้าที่กันทำ ถือว่าเป็น?",
+ "choices": [
+ "ตัวการร่วม",
+ "ผู้ใช้",
+ "ผู้สนับสนุน",
+ "ผู้รู้เห็น"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ความผิดที่ยอมความได้ หมายความว่า?",
+ "choices": [
+ "ฟ้องไม่ได้",
+ "ผู้เสียหายถอนคำร้องทำให้คดีระงับได้",
+ "ศาลต้องตัดสินเสมอ",
+ "ไม่มีโทษ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำเพื่อป้องกันสิทธิของตนพอสมควรแก่เหตุเรียกว่า?",
+ "choices": [
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบด้วยกฎหมาย",
+ "ความจำเป็น",
+ "ประมาท"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 6,
+ "th": "รับ coin เพิ่ม",
+ "en": "Fund",
+ "rarity": "legendary",
+ "imageUrl": "/Game/img/special-cards/card-6.png",
+ "txtImageUrl": "/Game/img/special-cards/card-6-txt.png"
+ }
+ },
+ "L5_C2": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "การลักทรัพย์โดยใช้กำลังประทุษร้ายหรือขู่เข็ญ เป็นความผิดฐานใด?",
+ "choices": [
+ "ลักทรัพย์",
+ "ชิงทรัพย์/ปล้นทรัพย์",
+ "ยักยอกทรัพย์",
+ "ฉ้อโกง"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "ลายนิ้วมือที่พบในที่เกิดเหตุ จัดเป็นพยานหลักฐานประเภทใด?",
+ "choices": [
+ "พยานบุคคล",
+ "พยานวัตถุ",
+ "พยานเอกสาร",
+ "คำรับสารภาพ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การปล้นทรัพย์ที่ร่วมกันตั้งแต่สามคนขึ้นไป กฎหมายกำหนดโทษอย่างไร?",
+ "choices": [
+ "เบากว่าปกติ",
+ "เท่าเดิม",
+ "หนักขึ้น",
+ "ยกเว้นโทษ"
+ ],
+ "correctIndex": 2
+ },
+ {
+ "type": "mcq",
+ "text": "พยานหลักฐานที่ได้มาโดยมิชอบด้วยกฎหมาย โดยหลักแล้วศาลจะ?",
+ "choices": [
+ "รับฟังได้เสมอ",
+ "อาจรับฟังไม่ได้ (ต้องห้าม)",
+ "ใช้ลงโทษได้ทันที",
+ "ส่งคืนเท่านั้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลัก 'ยกประโยชน์แห่งความสงสัยให้จำเลย' ใช้เมื่อใด?",
+ "choices": [
+ "เมื่อพยานชัดเจน",
+ "เมื่อมีข้อสงสัยตามสมควร",
+ "เมื่อจำเลยรับสารภาพ",
+ "เมื่ออัยการร้องขอ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การร่วมกระทำผิดโดยแบ่งหน้าที่กันทำ ถือว่าเป็น?",
+ "choices": [
+ "ตัวการร่วม",
+ "ผู้ใช้",
+ "ผู้สนับสนุน",
+ "ผู้รู้เห็น"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ความผิดที่ยอมความได้ หมายความว่า?",
+ "choices": [
+ "ฟ้องไม่ได้",
+ "ผู้เสียหายถอนคำร้องทำให้คดีระงับได้",
+ "ศาลต้องตัดสินเสมอ",
+ "ไม่มีโทษ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำเพื่อป้องกันสิทธิของตนพอสมควรแก่เหตุเรียกว่า?",
+ "choices": [
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบด้วยกฎหมาย",
+ "ความจำเป็น",
+ "ประมาท"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 7,
+ "th": "รับหลักฐานฟรี",
+ "en": "Free Evidence",
+ "rarity": "legendary",
+ "imageUrl": "/Game/img/special-cards/card-7.png",
+ "txtImageUrl": "/Game/img/special-cards/card--7txt.png"
+ }
+ },
+ "L5_C3": {
+ "questions": [
+ {
+ "type": "mcq",
+ "text": "ผู้ที่นำคดีอาญาขึ้นฟ้องต่อศาลแทนรัฐ เรียกว่าอะไร?",
+ "choices": [
+ "ทนายจำเลย",
+ "พนักงานอัยการ",
+ "ผู้พิพากษา",
+ "เจ้าพนักงานบังคับคดี"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลักการ 'สันนิษฐานว่าบริสุทธิ์ไว้ก่อน' หมายความว่าอย่างไร?",
+ "choices": [
+ "จำเลยผิดจนกว่าจะพิสูจน์ได้",
+ "จำเลยบริสุทธิ์จนกว่าจะพิสูจน์ว่าผิด",
+ "ศาลตัดสินทันที",
+ "ไม่ต้องมีพยาน"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การอุทธรณ์ หมายถึงอะไร?",
+ "choices": [
+ "การยอมรับโทษ",
+ "การขอให้ศาลสูงพิจารณาคดีใหม่",
+ "การถอนฟ้อง",
+ "การประนีประนอม"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "พยานหลักฐานที่ได้มาโดยมิชอบด้วยกฎหมาย โดยหลักแล้วศาลจะ?",
+ "choices": [
+ "รับฟังได้เสมอ",
+ "อาจรับฟังไม่ได้ (ต้องห้าม)",
+ "ใช้ลงโทษได้ทันที",
+ "ส่งคืนเท่านั้น"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "หลัก 'ยกประโยชน์แห่งความสงสัยให้จำเลย' ใช้เมื่อใด?",
+ "choices": [
+ "เมื่อพยานชัดเจน",
+ "เมื่อมีข้อสงสัยตามสมควร",
+ "เมื่อจำเลยรับสารภาพ",
+ "เมื่ออัยการร้องขอ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การร่วมกระทำผิดโดยแบ่งหน้าที่กันทำ ถือว่าเป็น?",
+ "choices": [
+ "ตัวการร่วม",
+ "ผู้ใช้",
+ "ผู้สนับสนุน",
+ "ผู้รู้เห็น"
+ ],
+ "correctIndex": 0
+ },
+ {
+ "type": "mcq",
+ "text": "ความผิดที่ยอมความได้ หมายความว่า?",
+ "choices": [
+ "ฟ้องไม่ได้",
+ "ผู้เสียหายถอนคำร้องทำให้คดีระงับได้",
+ "ศาลต้องตัดสินเสมอ",
+ "ไม่มีโทษ"
+ ],
+ "correctIndex": 1
+ },
+ {
+ "type": "mcq",
+ "text": "การกระทำเพื่อป้องกันสิทธิของตนพอสมควรแก่เหตุเรียกว่า?",
+ "choices": [
+ "บันดาลโทสะ",
+ "ป้องกันโดยชอบด้วยกฎหมาย",
+ "ความจำเป็น",
+ "ประมาท"
+ ],
+ "correctIndex": 1
+ }
+ ],
+ "specialCard": {
+ "cardId": 1,
+ "th": "เพิ่มเวลาเล่นมินิเกมทันที (+10 วินาที)",
+ "en": "Time Dilation",
+ "rarity": "legendary",
+ "imageUrl": "/Game/img/special-cards/card-1.png",
+ "txtImageUrl": "/Game/img/special-cards/card-1-txt.png"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js
index 8425156..0783c1b 100644
--- a/www/html/Game/public/js/play.js
+++ b/www/html/Game/public/js/play.js
@@ -8565,9 +8565,14 @@
continue;
}
const line = choiceText;
- const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14);
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
- let maxW = Math.max(56, tileSpanX * ts - 14);
+ /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */
+ while (fontPx > 8 && ctx.measureText(line).width > maxW) {
+ fontPx -= 1;
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ }
const lineH = fontPx * 1.2;
let textLines = canvasWordWrapLines(ctx, line, maxW);
if (!textLines.length) textLines = [line];
@@ -8830,12 +8835,16 @@
const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
const num = quizCarryPregameReadyNumerator();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
- if (st) {
- st.textContent = 'Ready Status : ' + num + '/' + tot;
- }
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
+ if (st) {
+ /* ตาม design 01-howto / 01-howto-host: โฮสต์เห็น "กำลังรอ..ผู้เล่นกด Ready" / ผู้เล่นเห็น "Ready Status" */
+ st.textContent = isMePlayHost()
+ ? (humansReady ? 'ผู้เล่นพร้อมแล้ว — กด START : ' + num + '/' + tot
+ : 'กำลังรอ..ผู้เล่นกด Ready : ' + num + '/' + tot)
+ : 'Ready Status : ' + num + '/' + tot;
+ }
if (primaryImg) {
primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
primaryImg.alt = humansReady ? 'START' : 'READY';
@@ -20935,7 +20944,7 @@
const missionMockOn = !!isMissionMockHudPlay();
root.classList.toggle('play-cyber-hud--quiz-tf-mock', missionMockOn);
document.documentElement.classList.toggle('play-mission-mock-hud', missionMockOn && on);
- document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && getMissionMockHudSkinPlay() === 'gauntlet_crown');
+ document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry'));
applyMissionMockHudCssVarsPlay(root);
const tfMockOn = root.classList.contains('play-cyber-hud--quiz-tf-mock');
if (tfMockOn) {
@@ -20964,7 +20973,7 @@
root.classList.toggle(
'play-cyber-hud--gauntlet-crown-strip',
!!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry())
- && !(missionMockOn && getMissionMockHudSkinPlay() === 'gauntlet_crown')),
+ && !(missionMockOn && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry'))),
);
root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry()));
root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay());
@@ -21200,7 +21209,7 @@
return;
}
const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry();
- const mg2MockHud = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ const mg2MockHud = isMissionMockHudPlay() && (getMissionMockHudSkinPlay() === 'gauntlet_crown' || getMissionMockHudSkinPlay() === 'quiz_carry');
const board = ul.closest('.play-cyber-scoreboard');
if (board) {
board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud && !mg2MockHud);
diff --git a/www/html/Game/public/js/play.js.bak-mg4howto-104501 b/www/html/Game/public/js/play.js.bak-mg4howto-104501
new file mode 100644
index 0000000..56125c0
--- /dev/null
+++ b/www/html/Game/public/js/play.js.bak-mg4howto-104501
@@ -0,0 +1,23214 @@
+(function () {
+ const BASE = '/Game';
+ function playDesignMainMenuHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') {
+ return globalThis.designMainMenuHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Menu/';
+ }
+ function playDesignMainLobbyHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') {
+ return globalThis.designMainLobbyHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Lobby/';
+ }
+ /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */
+ const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/';
+ /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */
+ const QUIZ_CARRY_MAP_FEEDBACK_IMG = {
+ correct: BASE + '/img/quiz-carry/icon-Correct.png',
+ incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png',
+ scorePlus: BASE + '/img/quiz-carry/score+10.png',
+ };
+ const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400;
+ const params = new URLSearchParams(window.location.search);
+ const spaceId = params.get('space');
+ function readStoredDisplayName() {
+ try {
+ return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim();
+ } catch (e) { return ''; }
+ }
+ let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น';
+ const previewMode = params.get('preview') === '1';
+ const specialQuizForceDebug = params.get('sqForce') === '1';
+ const forceDefaultCharacter = params.get('defaultChar') === '1';
+ const editorEmbedReturn = params.get('editorEmbed') === '1';
+ if (previewMode && editorEmbedReturn) {
+ try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ }
+ }
+ const playMapIdFromQuery = (params.get('map') || '').trim();
+ /** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
+ let playSessionMapId = playMapIdFromQuery;
+ /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
+ const quizCarryMissionSummaryMapId = 'mnorwqx1';
+ const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
+ /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
+ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
+ /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
+ const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
+ /** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */
+ const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
+ /** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */
+ const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o';
+ /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */
+ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
+ /** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */
+ const STACK_TOWER_FIXED_RENDER_W = 1920;
+ const STACK_TOWER_FIXED_RENDER_H = 1080;
+ /** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */
+ const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280;
+ /** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */
+ const STACK_TOWER_ROPE_DRAW_T1 = 1.0;
+ /** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */
+ const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25;
+ /** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */
+ const STACK_TOWER_POST50_PROGRESS_THRESH = 60;
+ const STACK_TOWER_POST50_ZOOM_MUL = 1.1;
+ /** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */
+ const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1;
+ const STACK_TOWER_POST50_ANIM_MS = 680;
+ /** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */
+ const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5;
+ /** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */
+ const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25;
+ /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */
+ const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
+ /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */
+ const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9;
+ /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */
+ const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14;
+ const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3;
+ /** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */
+ const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38;
+ /** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */
+ const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0;
+ /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */
+ const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62;
+ /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */
+ const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314;
+ /** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */
+ const STACK_TOWER_POST50_WIDTH_MULT = 0.6;
+
+ function stackBlockWidthTilesForPlay(baseTiles, heavy) {
+ const b = Number(baseTiles);
+ const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2;
+ const towerHeavyNarrow =
+ !!heavy &&
+ isStackTowerMissionUiMapPlay() &&
+ stackTowerMissionPhase === 'live' &&
+ stackMini &&
+ Number.isFinite(Number(stackMini.progressPct)) &&
+ Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH;
+ if (towerHeavyNarrow) {
+ const w = base * STACK_TOWER_POST50_WIDTH_MULT;
+ return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200));
+ }
+ if (!heavy) return base;
+ const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT;
+ return Math.min(3.2, Math.round(w * 200) / 200);
+ }
+ /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */
+ const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3;
+ const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400;
+ const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12;
+ /** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */
+ const SPACE_SHOOTER_ASTEROID_MAX_HP = 5;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11;
+
+ function spaceShooterAsteroidRadiusFromHpPlay(hp) {
+ const cap = SPACE_SHOOTER_ASTEROID_MAX_HP;
+ const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0));
+ const frac = h <= 0 ? 0 : h / cap;
+ return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP
+ + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac;
+ }
+
+ function spaceShooterShipFootprintCellsPlay(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ return {
+ cw: Math.max(1, Math.min(4, cw)),
+ ch: Math.max(1, Math.min(4, ch)),
+ };
+ }
+
+ /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */
+ function spaceShooterShipBodyScreenPxPlay(zDrawVal) {
+ const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1);
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const kw = 14 / 32;
+ const kh = 22 / 32;
+ return {
+ bodyW: cw * ts * zD * kw,
+ bodyH: ch * ts * zD * kh,
+ };
+ }
+
+ /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */
+ function spaceShooterShipHitRadiusWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const geom = Math.sqrt(Math.max(1, cw * ch));
+ const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts));
+ return Math.max(9, Math.min(cap, base * geom));
+ }
+
+ /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */
+ function spaceShooterShipEdgePadWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return 18;
+ const ts = tileSize || 32;
+ const { cw } = spaceShooterShipFootprintCellsPlay(mapData);
+ return Math.max(18, cw * ts * 0.48);
+ }
+
+ function spaceShooterResetMissionShipStateForAllPlay() {
+ function resetEnt(ent) {
+ if (!ent) return;
+ ent.spaceShooterHits = 0;
+ ent.spaceShooterEliminated = false;
+ ent.spaceShooterInvulnUntil = 0;
+ }
+ resetEnt(me);
+ others.forEach(function (o) {
+ resetEnt(o);
+ });
+ }
+
+ function spaceShooterMissionResolveShipAsteroidHitsPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return;
+ const now = performance.now();
+ const shipR = spaceShooterShipHitRadiusWorldPxPlay();
+
+ function tryHit(ent, cx, cy) {
+ if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return;
+ if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return;
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ if (!a) continue;
+ const dx = cx - a.x;
+ const dy = cy - a.y;
+ const rr = (a.r + shipR) * (a.r + shipR);
+ if (dx * dx + dy * dy > rr) continue;
+ ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1;
+ ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS;
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(ai, 1);
+ spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 });
+ if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) {
+ ent.spaceShooterEliminated = true;
+ spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 });
+ }
+ break;
+ }
+ }
+
+ if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy);
+ others.forEach(function (o) {
+ if (!o) return;
+ tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
+ });
+ if (spaceShooterMissionEveryParticipantEliminatedPlay()) {
+ endSpaceShooterMissionRound('all_dead');
+ }
+ }
+
+ /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */
+ function spaceShooterMissionEveryParticipantEliminatedPlay() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false;
+ if (myId == null) return false;
+ if (!me.spaceShooterEliminated) return false;
+ let anyAlive = false;
+ others.forEach(function (o) {
+ if (!o) return;
+ if (!o.spaceShooterEliminated) anyAlive = true;
+ });
+ return !anyAlive;
+ }
+
+ /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
+ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest';
+ let firstCharacterDefaultResolved = null;
+ /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */
+ let playCharacterIdRoster = [];
+ const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (c && c.id) playCharacterIdRoster.push(String(c.id));
+ });
+ }
+ if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
+ firstCharacterDefaultResolved = String(list[0].id);
+ } else {
+ firstCharacterDefaultResolved = '';
+ }
+ })
+ .catch(() => {
+ firstCharacterDefaultResolved = '';
+ playCharacterIdRoster = [];
+ })
+ .finally(() => {
+ try {
+ if (playBotsEnabled() && mapData) rebalancePreviewBots();
+ } catch (e) { /* map ยังไม่พร้อม */ }
+ });
+ const lobbyLevelParam = params.get('lobbyLevel');
+ const lobbyCaseParam = params.get('case');
+ if (lobbyLevelParam || lobbyCaseParam) {
+ try {
+ window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam };
+ } catch (e) { /* ignore */ }
+ }
+
+ const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h';
+
+ function isDetectiveMinigamePlay() {
+ if (params.get('detectiveReturn') === '1') return true;
+ try {
+ return sessionStorage.getItem('detectiveMinigameReturn') === '1';
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function buildRoomLobbyReturnHref() {
+ let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID);
+ const drParam = (params.get('displayRoom') || '').trim();
+ if (drParam) {
+ h += '&displayRoom=' + encodeURIComponent(drParam);
+ } else {
+ try {
+ const dr = localStorage.getItem('lastCreatedSpaceName');
+ if (dr) h += '&displayRoom=' + encodeURIComponent(dr);
+ } catch (e) { /* ignore */ }
+ }
+ try {
+ const meta = window.__detectiveLobbyMeta;
+ if (meta && meta.level != null && String(meta.level).trim()) {
+ h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim());
+ }
+ if (meta && meta.caseId != null && String(meta.caseId).trim()) {
+ h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim());
+ }
+ } catch (e2) { /* ignore */ }
+ try {
+ if (sessionStorage.getItem('detectiveMinigameReturn') === '1') {
+ h += '&detectiveReturn=1';
+ sessionStorage.removeItem('detectiveMinigameReturn');
+ }
+ } catch (e3) { /* ignore */ }
+ if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) {
+ h += '&detectiveReturn=1';
+ }
+ return h;
+ }
+
+ function finishDetectiveMinigameAndReturnLobby() {
+ quizCarrySessionEnded = true;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
+ const go = function () {
+ window.location.href = buildRoomLobbyReturnHref();
+ };
+ if (socket && socket.connected) {
+ socket.emit('detective-minigame-finished', {}, function (res) {
+ try {
+ if (res && Array.isArray(res.myPlayerEvidence)) {
+ var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; });
+ if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence));
+ else sessionStorage.removeItem('justiceDetectiveMyEvidence');
+ }
+ } catch (e) { /* ignore */ }
+ /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */
+ if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) {
+ showInSceneEvidenceReveal(res, go);
+ } else {
+ go();
+ }
+ });
+ } else {
+ go();
+ }
+ }
+
+ /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */
+ var __evidenceConfigCache = null;
+ function ensureEvidenceConfigLoaded(cb) {
+ if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; }
+ try {
+ fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); })
+ .catch(function () { cb({ cases: {} }); });
+ } catch (e) { cb({ cases: {} }); }
+ }
+
+ var EVIDENCE_RARITY_GLOW = {
+ common: 'rgba(168,180,200,.55)',
+ rare: 'rgba(94,170,255,.7)',
+ legendary: 'rgba(255,186,72,.85)',
+ };
+ var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' };
+
+ /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */
+ function openEvidenceCardLightboxPlay(imageUrl, label, rarity) {
+ if (!imageUrl) return;
+ var rar = String(rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var ov = document.getElementById('evidence-card-lightbox');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'evidence-card-lightbox';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)';
+ ov.innerHTML =
+ '× ' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ document.body.appendChild(ov);
+ var closeFn = function () { ov.style.display = 'none'; };
+ ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); });
+ }
+ var img = ov.querySelector('.ecl-img');
+ var cap = ov.querySelector('.ecl-cap');
+ if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; }
+ if (cap) cap.textContent = label || '';
+ ov.style.display = 'flex';
+ }
+
+ function showInSceneEvidenceReveal(res, onDone) {
+ var done = false;
+ var finish = function () { if (done) return; done = true; onDone(); };
+ var si = res.suspectIndex;
+ /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */
+ var awarded = (res.awardedCards && Array.isArray(res.awardedCards))
+ ? res.awardedCards.filter(function (c) { return c && c.imageUrl; })
+ : [];
+ if (!awarded.length) {
+ var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || [];
+ var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ var newN = Math.min(owned.length, 1 + freeN);
+ awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; });
+ }
+ if (!awarded.length) { finish(); return; }
+ var newN = awarded.length;
+ var ov = document.createElement('div');
+ ov.id = 'inscene-evidence-reveal';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;backdrop-filter:blur(3px)';
+ var title = document.createElement('div');
+ title.style.cssText = 'font:900 30px/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center';
+ title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!';
+ if (res.grade) {
+ var gradeEl = document.createElement('div');
+ gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9';
+ gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade;
+ title.appendChild(document.createElement('br'));
+ title.appendChild(gradeEl);
+ }
+ var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ if (freeBonus > 0) {
+ var bonusEl = document.createElement('div');
+ bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px';
+ bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ';
+ title.appendChild(document.createElement('br'));
+ title.appendChild(bonusEl);
+ }
+ var row = document.createElement('div');
+ row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw';
+ awarded.forEach(function (c, i) {
+ var rar = String(c.rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var cardWrap = document.createElement('div');
+ cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)';
+ var img = document.createElement('img');
+ img.src = c.imageUrl;
+ img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน';
+ img.style.cssText = 'display:block;width:min(34vw,260px);height:auto;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in';
+ img.title = 'แตะเพื่อดูใหญ่';
+ img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); });
+ img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); };
+ var tag = document.createElement('div');
+ tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92';
+ tag.textContent = EVIDENCE_RARITY_LABEL[rar] || '';
+ cardWrap.appendChild(img);
+ cardWrap.appendChild(tag);
+ row.appendChild(cardWrap);
+ setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160);
+ });
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม';
+ btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:14px 40px;font:800 18px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45)';
+ btn.addEventListener('click', function () {
+ ov.style.transition = 'opacity .25s'; ov.style.opacity = '0';
+ setTimeout(finish, 220);
+ });
+ ov.appendChild(title);
+ ov.appendChild(row);
+ ov.appendChild(btn);
+ document.body.appendChild(ov);
+ /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */
+ setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000);
+ }
+
+ /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */
+ function tryFinishDetectiveMinigameAndReturnLobby() {
+ if (!isDetectiveMinigamePlay()) return false;
+ finishDetectiveMinigameAndReturnLobby();
+ return true;
+ }
+
+ /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
+ const previewFillBots = previewMode && editorEmbedReturn;
+ let detectiveCaseFillBots = false;
+ let detectiveBotSlotCount = 0;
+ function playBotsEnabled() {
+ return previewFillBots || detectiveCaseFillBots;
+ }
+ function playBotTargetHeadcount() {
+ if (previewFillBots) return previewTargetHeadcount;
+ if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount;
+ return countPlayHumans();
+ }
+ /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */
+ const PLAY_EMBED_USER_ZOOM_MIN = 0.5;
+ const PLAY_EMBED_USER_ZOOM_MAX = 3;
+ const PLAY_EMBED_ZOOM_STEP_KEY = 1.12;
+ let playEmbedUserZoomMul = 1;
+ let lastPlayZDrawForInput = 1.4;
+ /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */
+ function playDomSyncZoom() {
+ const z = lastPlayZDrawForInput;
+ return Number.isFinite(z) && z > 0 ? z : zoom;
+ }
+ const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
+ const PREVIEW_BOT_PREFIX = '__pv_bot_';
+ let previewBotSeq = 0;
+ /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
+ let lastStackPreviewActorId = null;
+ let lastStackPreviewActorUntil = 0;
+ /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
+ const STACK_PREVIEW_TURN_COUNT = 6;
+ let stackPreviewTurnOrder = null;
+ let stackPreviewTurnIndex = 0;
+ let stackPreviewBotThinkUntil = 0;
+
+ function rebuildStackPreviewTurnOrder() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
+ stackPreviewTurnOrder = null;
+ return;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ stackPreviewTurnIndex = 0;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function advanceStackPreviewTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
+ stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function isStackPreviewHumanTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return !!(cur && cur.kind === 'human');
+ }
+
+ function getStackPreviewCurrentSeat() {
+ if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
+ }
+
+ function getStackDropActorSeat() {
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ return getStackPreviewCurrentSeat();
+ }
+ return 1;
+ }
+
+ function markStackNextDropVisualDirty() {
+ if (stackMini) stackMini.nextDropVisualDirty = true;
+ }
+
+ function pickStackBlockHeavyVisual(seat) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
+ /** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */
+ if (isStackTowerMissionUiMapPlay()) {
+ if (!(stackTowerMissionPhase === 'live' && stackMini)) return false;
+ const p = Number(stackMini.progressPct);
+ if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false;
+ return !!hU;
+ }
+ const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0)));
+ if (pct <= 0) return false;
+ if (!hU) return false;
+ if (Math.random() * 100 >= pct) return false;
+ return true;
+ }
+
+ function ensureStackNextDropVisual() {
+ if (!stackMini || !stackMini.nextDropVisualDirty) return;
+ stackMini.pendingDropSeat = getStackDropActorSeat();
+ stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat);
+ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy);
+ stackMini.nextDropVisualDirty = false;
+ }
+
+ function normalizePlayStackSixUrlsFromTiming(arr) {
+ const out = ['', '', '', '', '', ''];
+ if (!Array.isArray(arr)) return out;
+ for (let i = 0; i < 6; i++) {
+ out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
+ if (out[i]) ensureGauntletAssetImage(out[i]);
+ }
+ return out;
+ }
+
+ function resolveStackBlockSpriteRec(seat, heavy) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const tryHeavy = !!heavy;
+ const order = tryHeavy
+ ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]]
+ : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]];
+ for (let ti = 0; ti < order.length; ti++) {
+ const u = normalizeGauntletAssetUrlForPlay(order[ti] || '');
+ if (!u) continue;
+ return ensureGauntletAssetImage(u);
+ }
+ return null;
+ }
+
+ /**
+ * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback
+ * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain
+ * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult
+ */
+ function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) {
+ const o = opts || {};
+ const rec = resolveStackBlockSpriteRec(seat, heavy);
+ const img = rec && rec.img;
+ if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ if (drawW > 0 && drawH > 0) {
+ const scale = Math.max(drawW / iw, drawH / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = sx + (drawW - dw) * 0.5;
+ const dy = sy + (drawH - dh) * 0.5;
+ const prevSmooth = ctx.imageSmoothingEnabled;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(sx, sy, drawW, drawH);
+ ctx.clip();
+ try {
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ } finally {
+ ctx.imageSmoothingEnabled = prevSmooth;
+ }
+ ctx.restore();
+ if (o.missOverlay) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.42)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ }
+ return true;
+ }
+ }
+ const hue = ((hueFallback % 360) + 360) % 360;
+ ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)');
+ ctx.fillRect(sx, sy, drawW, drawH);
+ return false;
+ }
+
+ if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; }
+
+ const socket = io({ path: BASE + '/socket.io' });
+ window.__jdGameSocket = socket;
+ const canvas = document.getElementById('game-canvas');
+ const ctx = canvas.getContext('2d');
+ let mapData = null, tileSize = 32, myId = null;
+ let stackMini = null;
+ let stackFall = null;
+ /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */
+ let stackMissDebris = [];
+ let _stackDebrisLastT = 0;
+ /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */
+ let stackTowerSlingImg = null;
+ /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */
+ let stackTowerScorePlus10Img = null;
+ let stackTowerScorePlus20Img = null;
+ let stackTowerScoreLightImg = null;
+ let stackTowerScoreX2Img = null;
+ /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */
+ let stackTowerHeartMinusImg = null;
+ /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */
+ let stackTowerLifeBarImg = null;
+ let stackTowerLifeHitImg = null;
+ let stackTowerLifeFullImg = null;
+ /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */
+ let violentCrimeLifeBarImg = null;
+ let violentCrimeLifeHitImg = null;
+ let violentCrimeLifeFullImg = null;
+ /** @type {{ until: number } | null} */
+ let stackTowerHeartMinusFx = null;
+ const STACK_TOWER_HEART_MINUS_FX_MS = 1500;
+ const STACK_TOWER_PERFECT_GLOW_MS = 1500;
+ /** ป๊อปอัป +10 / +20 เหนือบล็อก */
+ const STACK_TOWER_SCORE_POPUP_MS = 2000;
+ /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */
+ const STACK_TOWER_PERFECT_X2_MS = 2200;
+ /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */
+ const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998;
+ let lastStackTickMs = performance.now();
+ let lastStackTowerScrollBgTickMs = performance.now();
+ let mapBackgroundImg = null;
+ /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */
+ const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp';
+ const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png';
+ const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png';
+ let playScrollBgIntroImg = null;
+ let playScrollBgLoopImg = null;
+ /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */
+ let playScrollBgPx = 0;
+ let playScrollBgSpeedPxPerSec = 56;
+ let playScrollBgOff = false;
+ /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */
+ let playScrollBgFlowDown = false;
+
+ function playScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID);
+ }
+
+ function playScrollBgDrawActive() {
+ if (!playScrollBgMapEligible() || playScrollBgOff) return false;
+ const a = playScrollBgIntroImg;
+ const b = playScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ function reloadPlayScrollBgFromMap() {
+ playScrollBgIntroImg = null;
+ playScrollBgLoopImg = null;
+ playScrollBgPx = 0;
+ if (!playScrollBgMapEligible()) return;
+ const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {};
+ playScrollBgOff = raw.enabled === false;
+ playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56));
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
+ playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ if (playScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ playScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { playScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { playScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function playScrollBgSyncInitialScrollToBottom() {
+ const intro = playScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */
+ if (playScrollBgFlowDown) {
+ playScrollBgPx = 0;
+ } else {
+ playScrollBgPx = Math.max(0, drawHIntro - chR);
+ }
+ }
+
+ function drawPlayScrollBgFullCanvas(cw, ch) {
+ const intro = playScrollBgIntroImg;
+ const loop = playScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const S = playScrollBgPx;
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 2;
+ const flowDown = playScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ function tickPlayScrollBg(dtSec) {
+ if (!playScrollBgDrawActive()) return;
+ playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec;
+ }
+
+ /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */
+ const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07';
+ /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */
+ const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000;
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
+ let gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ let gauntletCrownRunwayBgOff = false;
+ let gauntletCrownRunwayBgSpeedPxPerSec = 48;
+ let gauntletCrownRunwayBgScrollPx = 0;
+ let gauntletCrownRunwayBgFinishLatched = false;
+ /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */
+ let gauntletFinishPhaseSignaled = false;
+ /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */
+ let gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */
+ let gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */
+ let gauntletCrownRunwayClientMissionShown = false;
+ /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */
+ const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8;
+ const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png';
+ let gauntletCrownScorePenaltyImg = null;
+ function ensureGauntletCrownScorePenaltyImgPlay() {
+ if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg;
+ const im = new Image();
+ im.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ gauntletCrownScorePenaltyImg = im;
+ return im;
+ }
+ let lastGauntletCrownRunwayBgTickMs = 0;
+ /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */
+ let gauntletCrownRunwaySpawnScrollSnapped = false;
+
+ function gauntletCrownRunwayBgMapEligiblePlay() {
+ return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID);
+ }
+
+ function gauntletCrownRunwayBgNatDims(im) {
+ if (!im) return null;
+ if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height };
+ if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight };
+ return null;
+ }
+
+ function gauntletCrownRunwayBgImageSlotReadyPlay(im) {
+ return !!gauntletCrownRunwayBgNatDims(im);
+ }
+
+ function gauntletCrownRunwayBgStripWidthsAtCh(chR) {
+ const ch0 = Math.max(1, Math.round(chR));
+ const wOf = (im) => {
+ const d = gauntletCrownRunwayBgNatDims(im);
+ if (!d) return 0;
+ return Math.max(1, Math.round(d.nw * (ch0 / d.nh)));
+ };
+ return {
+ w0: wOf(gauntletCrownRunwayBgImgs[0]),
+ w1: wOf(gauntletCrownRunwayBgImgs[1]),
+ w2: wOf(gauntletCrownRunwayBgImgs[2]),
+ w3: wOf(gauntletCrownRunwayBgImgs[3]),
+ w4: wOf(gauntletCrownRunwayBgImgs[4]),
+ };
+ }
+
+ function gauntletCrownRunwayBgImagesReadyPlay() {
+ for (let i = 0; i < 5; i++) {
+ if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false;
+ }
+ return true;
+ }
+
+ /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */
+ function ensureGauntletCrownRunwayBgPlaceholdersPlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return;
+ const W = 960;
+ const H = 540;
+ const hues = [352, 210, 165, 225, 38];
+ for (let i = 0; i < 5; i++) {
+ if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue;
+ const c = document.createElement('canvas');
+ c.width = W;
+ c.height = H;
+ const g = c.getContext('2d');
+ const h0 = hues[i];
+ const grd = g.createLinearGradient(0, 0, W, H * 0.9);
+ grd.addColorStop(0, `hsl(${h0},26%,20%)`);
+ grd.addColorStop(0.45, `hsl(${h0},18%,14%)`);
+ grd.addColorStop(1, `hsl(${h0},12%,10%)`);
+ g.fillStyle = grd;
+ g.fillRect(0, 0, W, H);
+ g.strokeStyle = 'rgba(255,255,255,0.06)';
+ g.lineWidth = 1;
+ for (let x = 0; x < W; x += 48) {
+ g.beginPath();
+ g.moveTo(x, 0);
+ g.lineTo(x + H * 0.04, H);
+ g.stroke();
+ }
+ const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85);
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
+ vg.addColorStop(1, 'rgba(0,0,0,0.38)');
+ g.fillStyle = vg;
+ g.fillRect(0, 0, W, H);
+ g.fillStyle = 'rgba(255,255,255,0.1)';
+ g.font = '10px system-ui,sans-serif';
+ g.textAlign = 'right';
+ g.fillText(String(i + 1), W - 10, H - 8);
+ gauntletCrownRunwayBgImgs[i] = c;
+ }
+ }
+
+ function gauntletCrownRunwayBgDrawActivePlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false;
+ return gauntletCrownRunwayBgImagesReadyPlay();
+ }
+
+ function gauntletCrownRunwayFinishLineInArtFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let lineInArt = Number(raw.finishLineInFinishArtFrac);
+ if (!Number.isFinite(lineInArt)) {
+ lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12;
+ }
+ return Math.max(0, Math.min(0.92, lineInArt));
+ }
+
+ function gauntletCrownRunwayFinishLineStripXPlay(g) {
+ if (!g) return null;
+ return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay();
+ }
+
+ /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */
+ function gauntletCrownRunwayFinishLineMapWorldXPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const stripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (stripX == null) return null;
+ return stripX - gauntletCrownRunwayBgScrollPx;
+ }
+
+ function gauntletCrownRunwayStripFrozenPlay() {
+ return gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched;
+ }
+
+ /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */
+ function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() {
+ if (!mapData || !isGauntletCrownHeistMapPlay()) return null;
+ const ts = mapData.tileSize || 32;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ let leadTx = -Infinity;
+ const consider = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x);
+ if (!Number.isFinite(tx)) return;
+ leadTx = Math.max(leadTx, tx);
+ };
+ consider(me);
+ others.forEach((o, id) => {
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ consider(o);
+ });
+ if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null;
+ let alignWorld = (leadTx + cw * 0.5) * ts;
+ if (mx != null && Number.isFinite(mx)) {
+ alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts);
+ }
+ return alignWorld;
+ }
+
+
+ /**
+ * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px)
+ * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ
+ */
+ function gauntletCrownRunwayFinishAlignMapWorldXPlay() {
+ if (!mapData) return null;
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) {
+ return gauntletCrownRunwayFinishAlignLatchedWorldX;
+ }
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const ts = mapData.tileSize || 32;
+ const tileX = Number(raw.finishAlignMapTileX);
+ if (Number.isFinite(tileX)) return (tileX + 0.5) * ts;
+ const tileFrac = Number(raw.finishAlignMapTileFrac);
+ if (Number.isFinite(tileFrac)) {
+ const w = mapData.width || 20;
+ return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts;
+ }
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ return gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ }
+ return null;
+ }
+
+ /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */
+ function gauntletCrownRunwayFinishLineScreenFracPlay(g) {
+ if (!g) return null;
+ const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineWorldX == null) return null;
+ return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld);
+ }
+
+ function gauntletCrownRunwayFinishStopViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75;
+ let vp = Number(raw.finishStopViewportFrac);
+ if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp));
+ let legacy = Number(raw.finishStopScreenFrac);
+ if (Number.isFinite(legacy)) {
+ legacy = Math.max(0.08, Math.min(0.96, legacy));
+ return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42));
+ }
+ /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */
+ return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34;
+ }
+
+ function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let v = Number(raw.obstacleFadeStartViewportFrac);
+ /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */
+ if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9;
+ return Math.max(0.45, Math.min(1.15, v));
+ }
+
+ /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */
+ function gauntletCrownRunwayObstacleDrawAlphaPlay() {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return 1;
+ if (gauntletCrownRunwayBgFinishLatched) return 0;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return 1;
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return 1;
+ const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay();
+ if (screenFrac >= fadeStart) return 1;
+ if (screenFrac <= stopAt) return 0;
+ return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt);
+ }
+
+ function gauntletCrownRunwayBgHideObstaclesPlay() {
+ return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001;
+ }
+
+ /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */
+ function gauntletCrownRunwayAvatarRunAllowedPlay() {
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) {
+ if (gauntletCrownRunwayBgFinishLatched) return false;
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false;
+ return true;
+ }
+ return true;
+ }
+
+ function drawImageCoverCanvasPlay(ctx, img, cwR, chR) {
+ const d = gauntletCrownRunwayBgNatDims(img);
+ if (!d) return;
+ const iw = d.nw;
+ const ih = d.nh;
+ if (!iw || !ih) return;
+ const scale = Math.max(cwR / iw, chR / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = (cwR - dw) / 2;
+ const dy = (chR - dh) / 2;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ }
+
+ function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
+ const d0 = gauntletCrownRunwayBgNatDims(img);
+ if (!d0 || segW <= 0) return;
+ const natW = d0.nw;
+ const natH = d0.nh;
+ const v0 = scrollLeft;
+ const v1 = scrollLeft + cwR;
+ const o0 = Math.max(worldX0, v0);
+ const o1 = Math.min(worldX0 + segW, v1);
+ if (o1 <= o0) return;
+ const u0 = ((o0 - worldX0) / segW) * natW;
+ const u1 = ((o1 - worldX0) / segW) * natW;
+ const dx = o0 - scrollLeft;
+ const dw = o1 - o0;
+ /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */
+ ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR);
+ }
+
+ /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */
+ function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) {
+ const start = gauntletCrownRunwayBgImgs[0];
+ const L2 = gauntletCrownRunwayBgImgs[1];
+ const L3 = gauntletCrownRunwayBgImgs[2];
+ const L4 = gauntletCrownRunwayBgImgs[3];
+ const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ const cycle = w1 + w2 + w3;
+ const S = scrollView;
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR);
+ if (cycle <= 0) return;
+ const ws = [w1, w2, w3];
+ const ims = [L2, L3, L4];
+ const uTotal = Math.max(0, S - w0);
+ let cycN = Math.floor(uTotal / cycle);
+ let uRem = uTotal - cycN * cycle;
+ let si = 0;
+ while (si < 3 && uRem >= ws[si]) {
+ uRem -= ws[si];
+ si++;
+ }
+ if (si >= 3) {
+ cycN++;
+ si = 0;
+ uRem = 0;
+ }
+ let acc = 0;
+ for (let k = 0; k < si; k++) acc += ws[k];
+ let curLeft = w0 + cycN * cycle + acc;
+ let guard = 0;
+ let idx = si;
+ while (curLeft < S + cwR && guard++ < 500) {
+ drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
+ curLeft += ws[idx];
+ idx = (idx + 1) % 3;
+ }
+ }
+
+ /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */
+ function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) {
+ const S = scrollView;
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ const segs = [
+ { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 },
+ { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 },
+ { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 },
+ { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 },
+ { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 },
+ ];
+ for (let i = 0; i < segs.length; i++) {
+ const g = segs[i];
+ if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR);
+ }
+ }
+
+ /**
+ * @param {number} canvasW
+ * @param {number} canvasH
+ * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง)
+ */
+ function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ const finish = gauntletCrownRunwayBgImgs[4];
+ const align = worldAlign && typeof worldAlign === 'object'
+ && Number.isFinite(worldAlign.worldMinX)
+ && Number.isFinite(worldAlign.worldMaxX)
+ && Number.isFinite(worldAlign.camX)
+ && Number.isFinite(worldAlign.camY)
+ && Number.isFinite(worldAlign.zDraw)
+ && Number.isFinite(worldAlign.mapWpx)
+ && Number.isFinite(worldAlign.mapHpx);
+ /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */
+ /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */
+ const cwR = align
+ ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX)
+ : Math.max(1, Math.round(canvasW));
+ const chR = align
+ ? Math.max(1, Math.round(worldAlign.mapHpx))
+ : Math.max(1, Math.round(canvasH));
+ const scrollView = align
+ ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX)
+ : gauntletCrownRunwayBgScrollPx;
+ if (align) {
+ const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvasW, canvasH);
+ ctx.save();
+ ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
+ ctx.beginPath();
+ ctx.rect(0, 0, mapWpx, mapHpx);
+ ctx.clip();
+ ctx.translate(worldMinX, 0);
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ return;
+ }
+ ctx.save();
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ }
+
+ /**
+ * Shared runway strip math (viewport in strip space). null if not drawable.
+ * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34)
+ * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12)
+ * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9)
+ */
+ function gauntletCrownRunwayScrollGeometryPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const halfW = canvas.width / (2 * zGaRaw);
+ const worldMinX = gCam.px - halfW;
+ const cwWorld = canvas.width / zGaRaw;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ const w4Safe = Math.max(w4, 1);
+ const totalStrip = w0 + w1 + w2 + w3 + w4Safe;
+ if (!(totalStrip > 8)) return null;
+ const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX;
+ const right = scrollView + cwWorld;
+ const finishStart = w0 + w1 + w2 + w3;
+ const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe);
+ const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0;
+ const minScrollForFinishPass = geoMinScroll + wideVpPad;
+ return {
+ scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass,
+ };
+ }
+
+ /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */
+ function gauntletCrownRunwayScrollStripStopZoneReachedPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return false;
+ if (g.scrollView < g.minScrollForFinishPass - 0.01) return false;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay();
+ if (finishMapX == null) return false;
+ return finishMapX <= alignWorldX + 1.5;
+ }
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return false;
+ return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008;
+ }
+
+ /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */
+ function gauntletCrownRunwayFinishStopScrollPxPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineStripX == null) return null;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ return finishLineStripX - alignWorldX;
+ }
+ const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const targetScrollView = finishLineStripX - stopVp * g.cwWorld;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const worldMinX = gCam.px - canvas.width / (2 * zGaRaw);
+ return targetScrollView - worldMinX;
+ }
+
+ /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */
+ function gauntletCrownRunwaySnapScrollToFinishStopPlay() {
+ const t = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (t == null) return;
+ gauntletCrownRunwayBgScrollPx = t;
+ }
+
+ /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → snap เป๊ะ → เริ่ม freeze → (latch+mission ทีหลังใน tick) */
+ function gauntletCrownRunwayBeginFinishStopPlay() {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ /* แจ้ง server ครั้งเดียว: ถึงเส้นชัยแล้ว → หยุด obstacle + collision (กัน -10 ตอน obstacle หายแล้ว) */
+ if (!gauntletFinishPhaseSignaled) {
+ gauntletFinishPhaseSignaled = true;
+ try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ }
+ }
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { draw(); } catch (_d) { /* ignore */ }
+ }
+
+ /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */
+ function gauntletCrownRunwayMaintainFinishScrollPlay() {
+ if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return;
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return;
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ }
+
+ function resetGauntletCrownRunwaySpawnScrollSnap() {
+ gauntletCrownRunwaySpawnScrollSnapped = false;
+ }
+
+ /**
+ * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0)
+ * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32)
+ * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง
+ * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand.
+ */
+ function trySnapGauntletCrownRunwayScrollToSpawnsPlay() {
+ if (gauntletCrownRunwaySpawnScrollSnapped) return;
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return;
+ if (!gauntletCrownRunwayBgImagesReadyPlay()) return;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ if (!(w0 > 0)) return;
+ const slots = collectGauntletSpawnSlotsPlay(mapData);
+ let minTX = Infinity;
+ if (slots.length) minTX = Math.min(...slots.map((s) => s.x));
+ if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x));
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x));
+ });
+ if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1;
+ const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32));
+ gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX;
+ gauntletCrownRunwaySpawnScrollSnapped = true;
+ }
+
+ function reloadGauntletCrownRunwayBgFromMap() {
+ gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ gauntletCrownRunwayBgScrollPx = 0;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ lastGauntletCrownRunwayBgTickMs = 0;
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ gauntletCrownRunwayBgOff = raw.enabled === false;
+ gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
+ if (gauntletCrownRunwayBgOff) return;
+ const srcs = [
+ (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START,
+ (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2,
+ (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3,
+ (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4,
+ (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH,
+ ];
+ let pending = 5;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ ensureGauntletCrownRunwayBgPlaceholdersPlay();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ for (let i = 0; i < 5; i++) {
+ const im = new Image();
+ const idx = i;
+ im.onload = () => {
+ gauntletCrownRunwayBgImgs[idx] = im;
+ bump();
+ };
+ im.onerror = () => { bump(); };
+ im.src = srcs[i];
+ }
+ }
+
+ function tickGauntletCrownRunwayBgPlay(dtSec) {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */
+ if (specialQuizFreeze) return;
+ /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */
+ if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return;
+ if (gauntletCrownRunwayBgFinishLatched) return;
+
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) {
+ gauntletCrownRunwayBgFinishLatched = true;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) {
+ gauntletCrownRunwayClientMissionShown = true;
+ window.setTimeout(function () {
+ try {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownHowtoVisible = false;
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ showGauntletCrownMissionOverlay(gauntletCrownHeistBuildLocalCrownMissionPlay());
+ } catch (_e) { /* ignore */ }
+ try {
+ draw();
+ } catch (_e2) { /* ignore */ }
+ }, 340);
+ }
+ }
+ return;
+ }
+
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ try {
+ syncGauntletCrownJumpButton();
+ } catch (_sj) { /* ignore */ }
+ try {
+ draw();
+ } catch (_d) { /* ignore */ }
+ return;
+ }
+ const gcrInc = (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec;
+ /* ถ้าก้าวถัดไป "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น (ไม่วิ่งเลย) แล้วเริ่มจบเกมทันที */
+ const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null
+ && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01
+ && gauntletCrownRunwayBgScrollPx + gcrInc >= gcrLiveStopPx) {
+ gauntletCrownRunwayBgScrollPx = gcrLiveStopPx;
+ gauntletCrownRunwayBeginFinishStopPlay();
+ return;
+ }
+ gauntletCrownRunwayBgScrollPx += gcrInc;
+ }
+
+ /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */
+ const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
+ const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
+ let stackTowerScrollBgIntroImg = null;
+ let stackTowerScrollBgLoopImg = null;
+ let stackTowerScrollBgPx = 0;
+ let stackTowerScrollBgSpeedPxPerSec = 40;
+ let stackTowerScrollBgOff = false;
+ let stackTowerScrollBgFlowDown = false;
+ /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */
+ let stackTowerScrollBgSrawWorldBaselinePlay = null;
+ /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */
+ let stackTowerScrollBgLoopDrawHPlay = 0;
+ /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */
+ let stackTowerBgScrollStepAnimPlay = null;
+ let stackTowerBgScrollPendingStepDeltaPlay = 0;
+
+ function stackTowerScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID);
+ }
+
+ function stackTowerScrollBgImagesReady() {
+ if (!stackTowerScrollBgMapEligible()) return false;
+ const a = stackTowerScrollBgIntroImg;
+ const b = stackTowerScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */
+ function stackTowerScrollBgDrawActive() {
+ return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff;
+ }
+
+ function reloadStackTowerScrollBgFromMap() {
+ stackTowerScrollBgIntroImg = null;
+ stackTowerScrollBgLoopImg = null;
+ stackTowerScrollBgPx = 0;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgLoopDrawHPlay = 0;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ if (!stackTowerScrollBgMapEligible()) return;
+ const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {};
+ stackTowerScrollBgOff = raw.enabled !== true;
+ {
+ const spNum = Number(raw.speedPxPerSec);
+ stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ }
+ /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ stackTowerScrollBgFlowDown = !rawIsDownKeyword;
+ if (stackTowerScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ const intro0 = stackTowerScrollBgIntroImg;
+ const loop0 = stackTowerScrollBgLoopImg;
+ if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) {
+ const cwR0 = Math.max(1, Math.round(canvas.width));
+ stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth)));
+ }
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function stackTowerScrollBgSyncInitialScrollToBottom() {
+ const intro = stackTowerScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ if (stackTowerScrollBgFlowDown) {
+ stackTowerScrollBgPx = 0;
+ } else {
+ /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */
+ stackTowerScrollBgPx = drawHIntro - chR;
+ }
+ }
+
+ /**
+ * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop
+ * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ
+ */
+ function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) {
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(S) || 0;
+ if (s < dI) return s;
+ return s - Math.floor((s - dI) / dH) * dH;
+ }
+
+ /**
+ * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0)
+ * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด)
+ */
+ function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) {
+ if (Sraw0 == null) return 0;
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(Sraw) || 0;
+ const s0 = Number(Sraw0) || 0;
+ const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH);
+ const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH);
+ return fs - fs0;
+ }
+
+ /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */
+ function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) {
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0;
+ const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0;
+ const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
+ const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop);
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 96;
+ const flowDown = stackTowerScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */
+ y = drawHIntro + n * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */
+ function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) {
+ if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!loop || !loop.complete || !loop.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4);
+ const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx;
+ const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw
+ + getStackTowerPost50BgScrollExtraPxPlay(chR);
+ const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop;
+ let y = -scroll;
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ while (y < skyScreenH + drawHLoop) {
+ if (y + drawHLoop > 0) {
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop);
+ }
+ y += drawHLoop;
+ }
+ ctx.restore();
+ }
+
+ function tickStackTowerBgScrollStepAnimPlay() {
+ const q = stackTowerBgScrollStepAnimPlay;
+ if (!q) return;
+ const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur));
+ const e = 1 - Math.pow(1 - t, 3);
+ stackTowerScrollBgPx = q.from + (q.to - q.from) * e;
+ if (t >= 1) {
+ stackTowerScrollBgPx = q.to;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ if (stackTowerBgScrollPendingStepDeltaPlay !== 0) {
+ const d = stackTowerBgScrollPendingStepDeltaPlay;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ startStackTowerBgScrollStepAnimPlay(d);
+ }
+ }
+ }
+
+ function getStackTowerBgScrollStepEveryLayersPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const n = Math.floor(Number(raw.stepEveryLayers));
+ return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0;
+ }
+
+ function getStackTowerBgScrollStepAnimMsPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 520;
+ const t = Math.floor(Number(raw.stepAnimMs));
+ return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520;
+ }
+
+ function getStackTowerBgScrollStepScrollPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const d = Math.floor(Number(raw.stepScrollPx));
+ return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0;
+ }
+
+ function startStackTowerBgScrollStepAnimPlay(deltaPx) {
+ if (!stackTowerScrollBgDrawActive() || !deltaPx) return;
+ if (stackTowerBgScrollStepAnimPlay) {
+ stackTowerBgScrollPendingStepDeltaPlay += deltaPx;
+ return;
+ }
+ const dur = getStackTowerBgScrollStepAnimMsPlay();
+ stackTowerBgScrollStepAnimPlay = {
+ from: stackTowerScrollBgPx,
+ to: stackTowerScrollBgPx + deltaPx,
+ t0: performance.now(),
+ dur,
+ };
+ }
+
+ function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) {
+ if (!stackTowerScrollBgDrawActive()) return;
+ const every = getStackTowerBgScrollStepEveryLayersPlay();
+ if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return;
+ let delta = getStackTowerBgScrollStepScrollPxPlay();
+ if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200);
+ delta = Math.min(8000, Math.max(8, delta));
+ startStackTowerBgScrollStepAnimPlay(delta);
+ }
+
+ function getStackTowerStripReleaseGapWorldPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null;
+ const g = Number(raw.releaseGapWorldPx);
+ if (!Number.isFinite(g) || g < 0) return null;
+ return Math.min(800, g);
+ }
+
+ function updateStackTowerSwingYForStripGapPlay() {
+ if (!stackMini || !isStackTowerMissionUiMapPlay()) return;
+ const gap = getStackTowerStripReleaseGapWorldPxPlay();
+ if (gap == null) return;
+ const m = stackMini;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const nLay = m.layers ? m.layers.length : 0;
+ const topBlockTopY = m.floorWorldY - nLay * lh;
+ const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh);
+ m.swingWorldY = topBlockTopY - gap + swingLift;
+ }
+
+ function tickStackTowerScrollBg(dtSec) {
+ if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return;
+ if (!stackTowerScrollBgImagesReady()) return;
+ tickStackTowerBgScrollStepAnimPlay();
+ if (stackTowerBgScrollStepAnimPlay) return;
+ const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec;
+ if (spd <= 0) return;
+ /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */
+ stackTowerScrollBgPx += spd;
+ }
+
+ /**
+ * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50)
+ * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง)
+ */
+ function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const zPn = Number(zPan) > 0 ? zPan : zoom;
+ const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720));
+ const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn;
+ const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx);
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ let totalScreen = boostScreen + post50Screen;
+ if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth
+ && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) {
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth)));
+ const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen;
+ if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw;
+ const S0 = stackTowerScrollBgSrawWorldBaselinePlay;
+ let stripY;
+ if (stackTowerScrollBgFlowDown) {
+ const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop);
+ stripY = (boostScreen + post50Screen) * 2 - Sview;
+ } else {
+ stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0);
+ }
+ totalScreen = stripY;
+ } else if (stackTowerScrollBgDrawActive()) {
+ const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
+ totalScreen = px + boostScreen + post50Screen;
+ }
+ return totalScreen;
+ }
+
+ /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
+ let mapGridImageImgs = [];
+ let mapGridImageHeldImgs = [];
+ let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 };
+ const others = new Map();
+ /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */
+ let mg3TermLog = [];
+ let mg3TermLineNo = 0;
+ const MG3_TERM_MAX = 240;
+ function mg3TermPush(lines) {
+ const arr = Array.isArray(lines) ? lines : [lines];
+ for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i]));
+ while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift();
+ }
+ function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; }
+ function mg3TermBoot() {
+ mg3TermLog = []; mg3TermLineNo = 0;
+ mg3TermPush([
+ '[INIT] Booting decryption sequence...',
+ '[SYS] Locating target server...',
+ '[SYS] Connection established. Status: SECURE.',
+ '[HACK] Bypassing "Vault Lockdown" protocol...',
+ '[HACK] Disabling Cyber Security Check...',
+ '[LOAD] Injecting Decryption_Core.exe...',
+ '[READY] Awaiting manual code block alignment...',
+ '>>> STACKING SEQUENCE START <<<',
+ ]);
+ }
+ function mg3TermOnPlace(perfect, progressPct) {
+ mg3TermLineNo += 1;
+ const n = mg3TermLineNo;
+ if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) {
+ mg3TermPush([
+ '>>> Compile Line [100]: SUCCESS. Maximum stack reached.',
+ '[DATA]: Decryption progress: 100%. Master key generated.',
+ '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.',
+ '[SYS]: Evidence files unlocked. Initiating secure download...',
+ '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<',
+ ]);
+ } else if (perfect) {
+ mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!');
+ } else {
+ mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.');
+ }
+ }
+ function mg3TermOnMiss(attemptsRemaining) {
+ if (attemptsRemaining <= 0) {
+ mg3TermPush([
+ '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)',
+ '[ALERT]: Cyber Security Check triggered! Intruder traced.',
+ '[SYS]: Initiating "Vault Lockdown" protocol...',
+ '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<',
+ ]);
+ } else {
+ mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)');
+ }
+ }
+
+ /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
+ const STACK_PREVIEW_HUD_LOG_MAX = 8;
+ let stackPreviewHudLog = [];
+ function stackPreviewPushHudLog(line) {
+ if (!playBotsEnabled()) return;
+ stackPreviewHudLog.push(String(line || '').slice(0, 96));
+ while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
+ }
+ function stackPreviewLogStackDrop(hit, actor) {
+ if (!playBotsEnabled() || !hit) return;
+ const bid = actor && actor.botId;
+ let nm = 'NODE';
+ if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`);
+ else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`);
+ else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`);
+ }
+ const keys = {};
+ const characterImages = {};
+ /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */
+ const cyberHudScoreAvatarUrlCache = new Map();
+ const CYBER_HUD_AV_URL_CACHE_CAP = 56;
+ function createDefaultAvatarImg() {
+ const c = document.createElement('canvas');
+ c.width = 64; c.height = 64;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = '#7aa2f7';
+ ctx.beginPath();
+ ctx.arc(32, 22, 14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(32, 48, 18, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#1a1b26';
+ ctx.beginPath();
+ ctx.arc(28, 20, 3, 0, Math.PI * 2);
+ ctx.arc(36, 20, 3, 0, Math.PI * 2);
+ ctx.fill();
+ const img = new Image();
+ img.src = c.toDataURL('image/png');
+ return img;
+ }
+ const defaultAvatarImg = createDefaultAvatarImg();
+ const characterAnimations = {};
+ const characterIdleAnimations = {};
+ const CHARACTER_ANIM_FRAMES = 4;
+ const CHARACTER_ANIM_FRAME_MS = 200;
+
+ /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
+ /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */
+ function gauntletRunnerAnimActivePlay() {
+ try {
+ return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ } catch (e) { return false; }
+ }
+
+ function walkAnimPhaseIndex(now, isWalking) {
+ if (!isWalking) return 0;
+ const t = (typeof now === 'number' ? now : Date.now());
+ /* gauntlet วิ่งหลบ: ย่นเวลาเฟรม ~1.85× ให้ขาสลับเร็ว = วิ่ง (ไม่ใช่เดิน) */
+ const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.54 : CHARACTER_ANIM_FRAME_MS;
+ return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES;
+ }
+
+ /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */
+ function pickLoadedWalkFrameIndex(anim, phase) {
+ if (!anim || !anim.frames || !anim.frames.length) return -1;
+ const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1);
+ for (let k = maxK; k >= 0; k--) {
+ const f = anim.frames[k];
+ if (f && f.complete && f.naturalWidth) return k;
+ }
+ return -1;
+ }
+
+ /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */
+ function isUploadedCharAssetId(id) {
+ if (!id) return false;
+ const s = String(id).trim();
+ return /^char-\d/i.test(s);
+ }
+
+ function useMultiFrameIdleSpriteSheets(id) {
+ if (!id) return true;
+ return !isUploadedCharAssetId(id);
+ }
+
+ function ensureCharacterIdleAnim(id, dir) {
+ const d = dir || 'down';
+ /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */
+ if (!useMultiFrameIdleSpriteSheets(id)) {
+ const key = String(id) + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+ const key = id + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+
+ /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */
+ function idleAnimPhaseIndex(now) {
+ const t = typeof now === 'number' ? now : Date.now();
+ return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
+ }
+
+ function characterIdleSpritesVisible(id, dir) {
+ if (!id) return false;
+ const anim = ensureCharacterIdleAnim(id, dir || 'down');
+ if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true;
+ for (let i = 0; i < anim.frames.length; i++) {
+ const f = anim.frames[i];
+ if (f && f.complete && f.naturalWidth) return true;
+ }
+ return false;
+ }
+
+ /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
+ const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
+ const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
+ const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const playLayerMode = {};
+ /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
+ const playLayerAllDirsQueued = {};
+ /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */
+ let playCharLayerApi = { status: 'idle', map: null };
+ /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */
+ const playCharDiskLayersById = Object.create(null);
+
+ function ensurePlayCharLayerListFetch() {
+ if (playCharLayerApi.status !== 'idle') return;
+ playCharLayerApi.status = 'loading';
+ fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ const map = Object.create(null);
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (!c || !c.id) return;
+ playCharacterIdRoster.push(String(c.id));
+ map[c.id] = !!c.hasLayerFiles;
+ if (Array.isArray(c.layers) && c.layers.length) {
+ const st = new Set();
+ c.layers.forEach((n) => {
+ if (typeof n === 'string' && n.length) st.add(n);
+ });
+ if (st.size) playCharDiskLayersById[c.id] = st;
+ else delete playCharDiskLayersById[c.id];
+ } else {
+ delete playCharDiskLayersById[c.id];
+ }
+ });
+ }
+ playCharLayerApi = { status: 'done', map };
+ })
+ .catch(() => {
+ playCharacterIdRoster = [];
+ playCharLayerApi = { status: 'done', map: Object.create(null) };
+ });
+ }
+
+ /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */
+ function playCharLayerFromApi(characterId) {
+ if (playCharLayerApi.status !== 'done' || !characterId) return null;
+ if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) {
+ return playCharLayerApi.map[characterId];
+ }
+ return null;
+ }
+
+ /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */
+ function playCharDiskLayersSet(characterId) {
+ if (!characterId || playCharLayerApi.status !== 'done') return null;
+ const s = playCharDiskLayersById[characterId];
+ return s && s.size ? s : null;
+ }
+
+ const playLayerImageCache = new Map();
+ const playLayerCompositeCache = new Map();
+ function pickRandomPlayTint() {
+ return {
+ head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)],
+ hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)],
+ body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)],
+ };
+ }
+
+ /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */
+ function hashStringFnv1a32(str) {
+ let h = 2166136261 >>> 0;
+ const s = String(str || '');
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+ }
+
+ function playTintFromPeerId(id) {
+ const s = String(id || 'x');
+ const h0 = hashStringFnv1a32(s);
+ const h1 = hashStringFnv1a32(s + '\n1hair');
+ const h2 = hashStringFnv1a32(s + '\n2body');
+ return {
+ head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length],
+ hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length],
+ body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length],
+ };
+ }
+
+ const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
+ /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */
+ const PLAY_LOBBY_THEME_HEX = [
+ '#d72520', '#ef8508', '#efe237', '#5bb443',
+ '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe',
+ ];
+ const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ let playLobbyBotThemes = [];
+
+ function parsePlayLobbyThemeIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 8) ? v : null;
+ }
+
+ function parsePlayLobbySkinIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 3) ? v : null;
+ }
+
+ function playTintFromLobbyThemeIndices(themeIndex, skinIndex) {
+ const ti = parsePlayLobbyThemeIndex(themeIndex);
+ const si = parsePlayLobbySkinIndex(skinIndex);
+ const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : '';
+ const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : '';
+ if (!bodyHex && !headHex) return null;
+ return playTintFromLobbyRgb(bodyHex || null, headHex || null);
+ }
+
+ function playTintFromJoinPeer(peerRow) {
+ if (!peerRow) return null;
+ return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex);
+ }
+
+ function playTintForPreviewBotId(peerId) {
+ if (!isPreviewBotId(peerId)) return null;
+ const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10);
+ if (!Number.isFinite(idx) || idx < 0) return null;
+ const slot = playLobbyBotThemes[idx];
+ if (!slot) return null;
+ const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex);
+ if (!tint) return null;
+ try {
+ const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}');
+ map['__lobby_bot_' + idx] = {
+ colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '',
+ colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '',
+ };
+ sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map));
+ } catch (e) { /* ignore */ }
+ return tint;
+ }
+
+ function lobbyBotTintsStorageKeyPlay() {
+ const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default');
+ return 'justiceLobbyBotTints:' + sp;
+ }
+
+ function rgbCssToHexPlay(rgb) {
+ if (!rgb) return '';
+ const s = String(rgb).trim();
+ if (s.charAt(0) === '#') return s;
+ const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
+ if (!m) return '';
+ return '#' + [m[1], m[2], m[3]].map((n) => {
+ const v = Math.max(0, Math.min(255, parseInt(n, 10)));
+ return v.toString(16).padStart(2, '0');
+ }).join('');
+ }
+
+ function playTintFromLobbyRgb(themeRgb, skinRgb) {
+ const bodyHex = rgbCssToHexPlay(themeRgb);
+ const headHex = rgbCssToHexPlay(skinRgb);
+ if (!headHex && !bodyHex) return null;
+ return {
+ head: headHex || '#fbd5c4',
+ hair: bodyHex || '#fb4941',
+ body: bodyHex || '#fb4941',
+ };
+ }
+
+ function loadPlayTintFromLobbyPlayerStorage() {
+ try {
+ const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
+ if (raw) {
+ const j = JSON.parse(raw);
+ if (j && typeof j === 'object') {
+ if (j.head && j.body) {
+ return {
+ head: String(j.head),
+ hair: String(j.hair || j.body),
+ body: String(j.body),
+ };
+ }
+ const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb);
+ if (fromJson) return fromJson;
+ }
+ }
+ } catch (e) { /* ignore */ }
+ try {
+ const theme = localStorage.getItem('lobbyThemeColor') || '';
+ const skin = localStorage.getItem('lobbySkinTone') || '';
+ return playTintFromLobbyRgb(theme, skin);
+ } catch (e2) {
+ return null;
+ }
+ }
+
+ function loadPlayTintForBotPeerId(peerId) {
+ const sid = String(peerId || '');
+ let botKey = sid;
+ if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) {
+ botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length);
+ }
+ try {
+ const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay());
+ if (!raw) return null;
+ const map = JSON.parse(raw);
+ const saved = map && map[botKey];
+ if (!saved) return null;
+ return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */
+ function resolvePlayTintForPeer(peerId, peerRow) {
+ const sid = String(peerId || '');
+ const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null;
+ if (fromRow) return fromRow;
+ const previewTint = playTintForPreviewBotId(sid);
+ if (previewTint) return previewTint;
+ if (myId != null && sid === String(myId)) {
+ const meTint = loadPlayTintFromLobbyPlayerStorage();
+ if (meTint) return meTint;
+ }
+ if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint;
+ const botTint = loadPlayTintForBotPeerId(sid);
+ if (botTint) return botTint;
+ return playTintFromPeerId(sid);
+ }
+
+ function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) {
+ const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
+ if (!tint) return;
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ me.playTint = tint;
+ return;
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o) o.playTint = tint;
+ if (isPreviewBotId(sid)) playTintForPreviewBotId(sid);
+ }
+
+ function applyPlayLobbyTintSync(data) {
+ if (!data || typeof data !== 'object') return;
+ if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes;
+ if (Array.isArray(data.peers)) {
+ data.peers.forEach(function (p) {
+ if (!p || p.id == null) return;
+ applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex);
+ });
+ }
+ if (playBotsEnabled()) rebalancePreviewBots();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function getPlayTintForMissionAvatar(peerId) {
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ return me.playTint || resolvePlayTintForPeer(sid);
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o && o.playTint) return o.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */
+ function missionEnsureHumanInTopRanked(rows, maxN) {
+ const cap = Math.max(1, maxN || 5);
+ if (!Array.isArray(rows) || !rows.length) return [];
+ const humanId = myId != null ? String(myId) : null;
+ const humanRow = humanId
+ ? rows.find((r) => r && r.id != null && String(r.id) === humanId)
+ : rows.find((r) => r && r.isLocalHuman);
+ let top = rows.slice(0, cap);
+ if (!humanRow) return top;
+ const hid = humanRow.id != null ? String(humanRow.id) : '';
+ if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top;
+ if (top.length >= cap) top = top.slice(0, cap - 1);
+ top.push(humanRow);
+ return top;
+ }
+
+ function hexToRgb01(hex) {
+ const h = (hex || '').replace('#', '').trim();
+ if (h.length !== 6) return [1, 1, 1];
+ return [
+ parseInt(h.slice(0, 2), 16) / 255,
+ parseInt(h.slice(2, 4), 16) / 255,
+ parseInt(h.slice(4, 6), 16) / 255,
+ ];
+ }
+
+ function tintPlayLayerImageData(imageData, tintHex) {
+ const rgb = hexToRgb01(tintHex);
+ const tr = rgb[0], tg = rgb[1], tb = rgb[2];
+ const d = imageData.data;
+ for (let i = 0; i < d.length; i += 4) {
+ if (d[i + 3] < 12) continue;
+ const r = d[i], g = d[i + 1], b = d[i + 2];
+ const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ d[i] = Math.min(255, Math.round(tr * 255 * L));
+ d[i + 1] = Math.min(255, Math.round(tg * 255 * L));
+ d[i + 2] = Math.min(255, Math.round(tb * 255 * L));
+ }
+ }
+
+ function drawPlayTintedLayer(ctx, w, h, img, tintHex) {
+ if (!tintHex) {
+ ctx.drawImage(img, 0, 0, w, h);
+ return;
+ }
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const x = c.getContext('2d');
+ x.drawImage(img, 0, 0, w, h);
+ try {
+ const idata = x.getImageData(0, 0, w, h);
+ tintPlayLayerImageData(idata, tintHex);
+ x.putImageData(idata, 0, 0);
+ } catch (e) {
+ x.clearRect(0, 0, w, h);
+ x.drawImage(img, 0, 0, w, h);
+ }
+ ctx.drawImage(c, 0, 0, w, h);
+ }
+
+ function ensurePlayLayerImage(url) {
+ let img = playLayerImageCache.get(url);
+ if (!img) {
+ img = new Image();
+ img.src = url;
+ playLayerImageCache.set(url, img);
+ }
+ return img;
+ }
+
+ /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม
+ * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */
+ const _playPreloadedChars = new Set();
+ const _playPreloadedTintKeys = new Set();
+ function preloadPlayCharacterLayers(charId, _tries) {
+ if (!charId || _playPreloadedChars.has(charId)) return;
+ try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ }
+ try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ }
+ const diskSet = playCharDiskLayersSet(charId);
+ if (!diskSet) {
+ // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404)
+ const t = _tries || 0;
+ if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450);
+ return;
+ }
+ _playPreloadedChars.add(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); };
+ dirs.forEach(function (dir) {
+ PLAY_LAYER_ORDER.forEach(function (ln) {
+ if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์
+ if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face
+ for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) {
+ warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi));
+ }
+ warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0));
+ });
+ });
+ }
+
+ /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */
+ function preloadPlayTintForAvatar(charId, tint) {
+ if (!charId || !tint || typeof tint.head !== 'string') return;
+ const key = [charId, tint.head, tint.hair, tint.body].join('|');
+ if (_playPreloadedTintKeys.has(key)) return;
+ _playPreloadedTintKeys.add(key);
+ preloadPlayCharacterLayers(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const now = Date.now();
+ dirs.forEach(function (dir) {
+ const rawWalk = getCharacterFrame(charId, dir, now, true);
+ const rawIdle = getCharacterFrame(charId, dir, now, false);
+ if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint);
+ if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint);
+ });
+ }
+
+ function preloadPlayOccupantsTints() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) {
+ preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))));
+ }
+ others.forEach(function (o, id) {
+ if (!o || !o.characterId) return;
+ preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id));
+ });
+ }
+
+ /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */
+ function layerUrlCandidates(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir;
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ add(base + '_layer_' + layerName + '.png');
+ return out;
+ }
+
+ /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */
+ function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว
+ add(base + '_layer_' + layerName + '.png');
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ return out;
+ }
+
+ function shadowUrlCandidates(id, dir, frameIndex) {
+ const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ function shadowUrlCandidatesIdle(id, dir, frameIndex) {
+ const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
+ function resolvePlayLayerImage(urls) {
+ for (let i = 0; i < urls.length; i++) {
+ const img = ensurePlayLayerImage(urls[i]);
+ if (!img.complete) return { status: 'pending' };
+ if (img.naturalWidth > 0) return { status: 'ok', img };
+ }
+ return { status: 'missing' };
+ }
+
+ function ensurePlayLayerProbesAllDirections(characterId) {
+ if (!characterId || playLayerAllDirsQueued[characterId]) return;
+ playLayerAllDirsQueued[characterId] = true;
+ ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
+ }
+
+ function playCharAnyDirectionLayered(characterId) {
+ if (!characterId) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
+ }
+
+ /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
+ function playCharLayerDiscoveryPending(characterId) {
+ if (!characterId) return true;
+ const api = playCharLayerFromApi(characterId);
+ if (api === true || api === false) return false;
+ if (playCharAnyDirectionLayered(characterId)) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => {
+ const m = playLayerMode[characterId + '|' + d];
+ return m === undefined || m === 'pending';
+ });
+ }
+
+ function ensurePlayLayerProbe(characterId, dir) {
+ const key = characterId + '|' + dir;
+ if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
+ if (playLayerMode[key] === 'pending') return;
+ playLayerMode[key] = 'pending';
+ const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
+ let ci = 0;
+ function tryNextProbe() {
+ if (playLayerMode[key] !== 'pending') return;
+ if (ci >= cands.length) {
+ playLayerMode[key] = 'none';
+ return;
+ }
+ const url = cands[ci++];
+ const img = ensurePlayLayerImage(url);
+ const fin = () => {
+ if (playLayerMode[key] !== 'pending') return;
+ if (img.naturalWidth > 0) {
+ playLayerMode[key] = 'layered';
+ return;
+ }
+ tryNextProbe();
+ };
+ img.onload = fin;
+ img.onerror = fin;
+ if (img.complete) fin();
+ }
+ tryNextProbe();
+ }
+
+ function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
+ if (!id) return 0;
+ const key = id + '_' + dir;
+ const anim = characterAnimations[key];
+ if (!anim) return 0;
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ return fi >= 0 ? fi : 0;
+ }
+
+ function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
+ const useIdle = opts && opts.idle;
+ if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
+ const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const cctx = c.getContext('2d');
+ let anyTintColorLayer = false;
+ let skippedShadowWhilePending = false;
+ const diskLayers = playCharDiskLayersSet(id);
+ /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */
+ const idleLayerStripes = !!useIdle;
+ const resDir = dir;
+ const resFi = frameIndex;
+ for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
+ const layerName = PLAY_LAYER_ORDER[li];
+ if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
+ /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */
+ if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') {
+ continue;
+ }
+ let urls;
+ if (layerName === 'shadow') {
+ urls = idleLayerStripes
+ ? shadowUrlCandidatesIdle(id, dir, frameIndex)
+ : shadowUrlCandidates(id, resDir, resFi);
+ } else {
+ urls = idleLayerStripes
+ ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex)
+ : layerUrlCandidates(id, resDir, layerName, resFi);
+ }
+ const r = resolvePlayLayerImage(urls);
+ /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
+ if (r.status === 'pending' && layerName === 'shadow') {
+ skippedShadowWhilePending = true;
+ continue;
+ }
+ if (r.status === 'pending') return null;
+ if (r.status === 'missing') continue;
+ const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
+ if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
+ drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
+ }
+ /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
+ if (!anyTintColorLayer) return null;
+ return { canvas: c, skipCache: skippedShadowWhilePending };
+ }
+
+ function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
+ if (!tint || !characterId || !rawImg) return rawImg;
+ ensurePlayCharLayerListFetch();
+ ensurePlayLayerProbesAllDirections(characterId);
+ const dirn = dir || 'down';
+ const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
+ const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
+
+ if (playCharLayerDiscoveryPending(characterId)) return rawImg;
+
+ const apiFlag = playCharLayerFromApi(characterId);
+ /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
+ const charHasLayerFiles = apiFlag === true
+ || (apiFlag == null && playCharAnyDirectionLayered(characterId));
+
+ /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */
+ if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) {
+ return rawImg;
+ }
+ const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
+
+ if (charHasLayerFiles) {
+ if (!isWalking) {
+ const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase;
+ const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
+ const hitI = playLayerCompositeCache.get(cacheKeyIdle);
+ if (hitI) return hitI;
+ const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true });
+ if (packI && packI.canvas) {
+ if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
+ return packI.canvas;
+ }
+ }
+ const hit = playLayerCompositeCache.get(cacheKeyWalk);
+ if (hit) return hit;
+ const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
+ if (pack && pack.canvas) {
+ if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
+ return pack.canvas;
+ }
+ return rawImg;
+ }
+
+ /*
+ * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม)
+ */
+ return rawImg;
+ }
+
+ function getCharacterImg(id, direction) {
+ if (!id) return null;
+ const key = id + '_' + (direction || 'down');
+ if (characterImages[key]) return characterImages[key];
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
+ characterImages[key] = img;
+ return img;
+ }
+
+ function getCharacterFrame(id, direction, now, isWalking) {
+ if (!id) return null;
+ const dir = direction || 'down';
+ const t = typeof now === 'number' ? now : Date.now();
+ if (!isWalking) {
+ const idleAnim = ensureCharacterIdleAnim(id, dir);
+ let idlePhase = idleAnimPhaseIndex(t);
+ if (isUploadedCharAssetId(id)) idlePhase = 0;
+ const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
+ if (idleFi >= 0) return idleAnim.frames[idleFi];
+ const idfb = idleAnim.fallback;
+ if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
+ }
+ const key = id + '_' + dir;
+ let anim = characterAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterAnimations[key] = anim;
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ anim.fallback = getCharacterImg(id, dir);
+ }
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ if (fi >= 0) return anim.frames[fi];
+ const fb = anim.fallback;
+ if (fb && fb.complete && fb.naturalWidth) return fb;
+ return null;
+ }
+
+ function getAvatarImg(characterId, direction, now, isWalking) {
+ const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
+ if (img) return img;
+ return defaultAvatarImg;
+ }
+
+ /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */
+ const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:';
+ const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:';
+ const playLobbyIdleAvatarCache = new Map();
+
+ function playLobbyIdleAvatarSrcForCharacter(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return '';
+ try {
+ const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || '';
+ if (ss && ss.indexOf('data:image/') === 0) return ss;
+ const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || '';
+ if (ls && ls.indexOf('data:image/') === 0) return ls;
+ } catch (e) { /* ignore */ }
+ return '';
+ }
+
+ function ensurePlayLobbyIdleAvatarImage(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return;
+ if (playLobbyIdleAvatarCache.get(id + '_loading')) return;
+ const src = playLobbyIdleAvatarSrcForCharacter(id);
+ if (!src) return;
+ const img = new Image();
+ img.onload = function () {
+ playLobbyIdleAvatarCache.set(id, img);
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.onerror = function () {
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.src = src;
+ playLobbyIdleAvatarCache.set(id + '_loading', img);
+ }
+
+ function getPlayLobbyIdleAvatarImageSync(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return null;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return hit;
+ ensurePlayLobbyIdleAvatarImage(id);
+ return null;
+ }
+
+ /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */
+ function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) {
+ const dir = direction || 'down';
+ const walk = !!isWalking;
+ if (dir === 'down' && useLobbyIdleComposite && !walk) {
+ const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId);
+ if (lobbyImg) return lobbyImg;
+ }
+ const rawImg = getAvatarImg(characterId, dir, now, walk);
+ if (!playTint || !characterId) return rawImg;
+ return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg;
+ }
+
+ function preloadPlayLobbyIdleAvatarsForOccupants() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) ensurePlayLobbyIdleAvatarImage(meCid);
+ others.forEach(function (o) {
+ if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId);
+ });
+ }
+
+ function playHideTopChrome() {
+ try {
+ return document.documentElement.classList.contains('play-hide-top-chrome');
+ } catch (e) {
+ return true;
+ }
+ }
+
+ function getStoredCharacterId() {
+ try {
+ const v = localStorage.getItem('gameCharacterId');
+ if (v) return v;
+ } catch (e) { /* ignore */ }
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ function getPlayCharacterId() {
+ if (forceDefaultCharacter) {
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ return getStoredCharacterId();
+ }
+
+ /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */
+ function getJdPlayerKey() {
+ try {
+ let k = (localStorage.getItem('jdPlayerKey') || '').trim();
+ if (!k || k.length < 8) {
+ k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ localStorage.setItem('jdPlayerKey', k);
+ }
+ return k;
+ } catch (e) {
+ return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ }
+ }
+
+ /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */
+ function pickPreviewBotCharacterId(botSlotIndex) {
+ const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0);
+ const roster = playCharacterIdRoster;
+ const humanId = String(getPlayCharacterId() || '');
+ if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID;
+ const alts = roster.filter((id) => id !== humanId);
+ const pool = alts.length ? alts : roster.slice();
+ return pool[idx % pool.length] || humanId || roster[0];
+ }
+ const MOVE_SPEED = 0.15;
+ /** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */
+ const QUIZ_CARRY_WALK_SPEED_MULT = 1.42;
+ /** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */
+ let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ const PATH_ARRIVE_THRESH = 0.15;
+
+ function clampQuizCarryWalkSpeedMultClient(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return def;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+ }
+
+ function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) {
+ const base = QUIZ_CARRY_WALK_SPEED_MULT;
+ if (!s || typeof s !== 'object') return base;
+ const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim();
+ const mult = Number(s.carryWalkSpeedMult);
+ const mapId = (currentPlayMapId() || '').trim();
+ if (forId && mapId && forId === mapId && Number.isFinite(mult)) {
+ return clampQuizCarryWalkSpeedMultClient(mult, base);
+ }
+ return base;
+ }
+
+ function applyQuizCarryWalkSpeedFromSettingsObj(s) {
+ if (!isQuizCarry()) {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ return;
+ }
+ quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s);
+ }
+
+ function moveSpeedTilesThisFrameForWalk() {
+ return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1);
+ }
+
+ function isMovementKey(code) {
+ return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
+ }
+ function isChatFocused() {
+ return false;
+ }
+ let zoom = 1.4;
+ let froggerScore = 0;
+ let lastFroggerKey = 0;
+ let gauntletObstacles = [];
+ /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
+ let gauntletObsRenderPrev = [];
+ let gauntletObsRenderNext = [];
+ let gauntletObsBlendT0 = 0;
+ let meGauntletJumpTicks = 0;
+ /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
+ let meGauntletJumpVis = 0;
+ /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
+ let gauntletRuntimeTickMs = 220;
+ let gauntletRuntimeJumpTicks = 16;
+ /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
+ let playStackSwingHz = 0.55;
+ /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
+ let playStackBlockWidthTiles = null;
+ /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */
+ let playStackTowerMissionTimeSec = 90;
+ /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */
+ let playStackTeamMissesMax = 3;
+ /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
+ let playStackTowerProgressBlocks = 50;
+ /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */
+ let playStackBlockNormalUrls = ['', '', '', '', '', ''];
+ let playStackBlockHeavyUrls = ['', '', '', '', '', ''];
+ /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */
+ let playStackHeavyBlockPercent = 35;
+ /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
+ let playJumpSurviveJumpHeightMult = 1.5;
+ /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */
+ let playJumpSurviveMissionTimeSec = 0;
+ /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
+ let playJumpSurvivePlatformTiles = [
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ ];
+ /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */
+ let playSpaceShooterMissionTimeSec = 0;
+ /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */
+ let playSpaceShooterShipImageUrls = ['', '', '', '', '', ''];
+ /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */
+ let playSpaceShooterAsteroidSpriteUrls = [];
+ let playSpaceShooterAsteroidExplodeFrameMs = 70;
+ /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
+ let playSpaceShooterAsteroidIntervalMs = 1040;
+ /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */
+ let playSpaceShooterShipDamageOverlayUrls = ['', '', ''];
+ /** balloon_boss / Mega Virus — จาก game-timing */
+ let playBalloonBossMissionTimeSec = 0;
+ let playBalloonBossBossImageUrl = '';
+ let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
+ let playBalloonBossPlayerBalloonFallbackUrl = '';
+ /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
+ let gauntletRuntimeTimeLimitSec = 0;
+ /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
+ let gauntletEndsAtMs = null;
+ /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */
+ let gauntletCrownRunHeldRemote = false;
+ /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */
+ let gauntletCrownPregamePhase = null;
+ let gauntletCrownLobbyReadyMap = {};
+ let gauntletCrownCountdownTimer = null;
+ let lastGauntletJumpKey = 0;
+ /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
+ let gauntletLaneImageUrls = [];
+ let gauntletLaserTopUrl = '';
+ let gauntletLaserBottomUrl = '';
+ let gauntletLaserLineUrl = '';
+ let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
+ let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
+ let gauntletLaserLineWidthPx = 2;
+ const gauntletAssetImageCache = new Map();
+
+ function encodeSpacesInUrlPath(t) {
+ const s = String(t || '');
+ const q = s.indexOf('?');
+ const pathPart = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ return pathPart.replace(/ /g, '%20') + query;
+ }
+
+ function decodeAssetUrlPercentRuns(t) {
+ let s = String(t || '');
+ const q = s.indexOf('?');
+ let base = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ for (let i = 0; i < 2; i += 1) {
+ try {
+ const d = decodeURIComponent(base);
+ if (d === base) break;
+ base = d;
+ } catch (e) {
+ break;
+ }
+ }
+ return base + query;
+ }
+
+ function normalizeGameAssetUrlForWebPlay(t) {
+ let s = String(t || '');
+ s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
+ s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
+ return s;
+ }
+
+ /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
+ function normalizeGauntletAssetUrlForPlay(u) {
+ if (typeof u !== 'string') return '';
+ let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim()));
+ const bare0 = raw.split('?')[0].replace(/\/+$/, '');
+ if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) {
+ raw = '/Game/img/MegaVirus/Artboard%209.png';
+ }
+ if (!raw) return '';
+ if (/^https?:\/\//i.test(raw)) {
+ const z = raw.replace(/\/+$/, '');
+ return z ? encodeSpacesInUrlPath(z) : '';
+ }
+ const qIdx = raw.indexOf('?');
+ const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, '');
+ const qs = qIdx >= 0 ? raw.slice(qIdx) : '';
+ if (!base) return '';
+ if (/\.{4,}/.test(base)) return '';
+ if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs);
+ if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs);
+ return encodeSpacesInUrlPath(base + qs);
+ }
+
+ function ensureGauntletAssetImage(url) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : '');
+ if (!u) return null;
+ let rec = gauntletAssetImageCache.get(u);
+ if (rec) return rec;
+ rec = { img: new Image(), ready: false };
+ if (/^https?:\/\//i.test(u)) {
+ try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ }
+ }
+ rec.img.onload = function () { rec.ready = true; };
+ rec.img.onerror = function () { rec.ready = false; };
+ rec.img.src = u;
+ /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */
+ if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true;
+ else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false;
+ gauntletAssetImageCache.set(u, rec);
+ return rec;
+ }
+
+ /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */
+ function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) {
+ const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim());
+ const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim());
+ if (per) {
+ const rPer = ensureGauntletAssetImage(per);
+ const img = rPer && rPer.img;
+ if (img && img.complete && img.naturalWidth > 0) return rPer;
+ if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb);
+ return rPer;
+ }
+ if (fb) return ensureGauntletAssetImage(fb);
+ return null;
+ }
+
+ function pickGauntletLaneImageRec(obsId) {
+ if (!gauntletLaneImageUrls.length) return null;
+ const n = gauntletLaneImageUrls.length;
+ const idx = Math.abs(Number(obsId) | 0) % n;
+ const u = gauntletLaneImageUrls[idx];
+ return u ? ensureGauntletAssetImage(u) : null;
+ }
+
+ function clampClientLaserColor(s, def) {
+ const t = String(s || '').trim().slice(0, 100);
+ if (!t || /[<>"'`]/.test(t)) return def;
+ return t;
+ }
+
+ let playQuizPhaseLocal = null;
+ let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ let playQuizText = '';
+ let playQuizPhaseEndsAt = 0;
+ let playQuizTimerInterval = null;
+ /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */
+ let playQuizQuestionIndex = 0;
+ let playQuizQuestionTotal = 0;
+ /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
+ let previewQuizPool = [];
+ /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */
+ let previewQuizQIndex = 0;
+ let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
+ let previewQuizStep = 'read';
+ let previewQuizCurrent = null;
+ let playLiveQuizScores = {};
+ /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */
+ let playQuizEverWrong = {};
+ /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */
+ const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png';
+ let quizWrongGhostImg = null;
+ function getQuizWrongGhostImg() {
+ if (quizWrongGhostImg) return quizWrongGhostImg;
+ const img = new Image();
+ img.decoding = 'async';
+ img.src = QUIZ_WRONG_GHOST_URL;
+ quizWrongGhostImg = img;
+ return img;
+ }
+ /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */
+ function quizGhostAreaGridPlay() {
+ const g = mapData && mapData.quizGhostArea;
+ if (!Array.isArray(g)) return null;
+ for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; }
+ return null;
+ }
+ function quizMeIsGhostPlay() {
+ return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse));
+ }
+ function quizGhostConfineActivePlay() {
+ return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay();
+ }
+ /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */
+ function quizGhostFootprintInsidePlay(x, y) {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return true;
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(','); const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+ /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */
+ function warpMeIntoQuizGhostZonePlay() {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return false;
+ if (quizGhostFootprintInsidePlay(me.x, me.y)) return true;
+ const xs = [], ys = [];
+ for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } }
+ if (!xs.length) return false;
+ const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2);
+ const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2);
+ const cand = [];
+ for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]);
+ cand.sort((a, b) => a[2] - b[2]);
+ let tx = xs[0], ty = ys[0];
+ for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } }
+ me.x = tx; me.y = ty; me.tx = tx; me.ty = ty;
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ return true;
+ }
+
+ /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */
+ function isQuizWrongGhostActiveForEnt(entId) {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ const sid = String(entId == null ? '' : entId);
+ const isMeEnt = (myId != null && sid === String(myId));
+ return isMeEnt
+ ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse))
+ : !!playQuizEverWrong[sid];
+ }
+
+ /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย
+ * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller)
+ * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px)
+ * - syBottom = พิกัด Y เท้า avatar บนจอ (px)
+ * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี
+ */
+ function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) {
+ const img = getQuizWrongGhostImg();
+ if (!img || !img.complete || !img.naturalWidth) return;
+ /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */
+ const hPx = Math.max(24, Number(heightPx) || 0);
+ const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
+ const wPx = hPx * ratio;
+ const sid = String(entId == null ? '' : entId);
+ const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04);
+ /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */
+ const drawX = sxCenter - wPx / 2;
+ const drawY = syBottom - hPx + tBob;
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.drawImage(img, drawX, drawY, wPx, hPx);
+ ctx.restore();
+ /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */
+ if (nameLabel) {
+ ctx.save();
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(255,255,255,0.95)';
+ ctx.strokeStyle = 'rgba(0,0,0,0.85)';
+ ctx.lineWidth = 3;
+ const ny = syBottom + 14;
+ ctx.strokeText(nameLabel, sxCenter, ny);
+ ctx.fillText(nameLabel, sxCenter, ny);
+ ctx.restore();
+ }
+ }
+ /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope)
+ * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */
+ function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) {
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cxWorld = (axTile + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ayTile + ch) * tileSize;
+ const [sx, sy] = w2s(cxWorld, cyBottomWorld);
+ /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */
+ const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35;
+ return { sx, sy, sizeH };
+ }
+ /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */
+ const QUIZ_TF_POINTS_PER_CORRECT = 10;
+ const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png';
+ const QUIZ_TF_SCORE_POPUP_MS = 1700;
+ /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */
+ const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7;
+ const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112;
+ let quizTfScorePopups = [];
+ let quizTfScorePlusImg = null;
+ /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
+ const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
+ /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */
+ const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000;
+ /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
+ let quizCarryPool = [];
+ let quizCarryCurrent = null;
+ /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
+ let quizCarryEmbedPendingQuestion = null;
+ let quizCarryEmbedCountdownStartAt = 0;
+ let quizCarryEmbedCountdownEndAt = 0;
+ /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */
+ let quizCarryEmbedPreOptionCountdownStartAt = 0;
+ let quizCarryEmbedPreOptionCountdownEndAt = 0;
+ /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
+ let quizCarryOptionRevealAt = 0;
+ let quizCarryAnswerCloseAt = 0;
+ /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */
+ let quizCarryAnswerTimeupAwaitNext = false;
+ let quizCarryAnswerRoundTimeupAutoT = null;
+ let quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
+ /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */
+ let quizCarrySessionLength = 0;
+ let quizCarryRoundsCompleted = 0;
+ let quizCarrySessionEnded = false;
+ /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */
+ let quizCarryResultEndTimer = null;
+ /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */
+ let embedPreviewLobbyReturnTimer = null;
+ /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */
+ let quizCarrySessionCompleteResultToSummaryT = null;
+ /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */
+ let quizCarryPregameActive = false;
+ let playHostId = null;
+ /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */
+ let quizCarryLobbyReadyMap = {};
+ /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */
+ let quizCarryMapPanelTheme = null;
+ /** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */
+ let quizMapPanelTheme = null;
+ /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */
+ let quizCarryEmbedCountdownTheme = null;
+ /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */
+ let quizCarryChoicePlaqueThemes = null;
+ /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */
+ let quizCarryPlaqueMapScale = 1.25;
+ const quizCarryChoiceImageCache = new Map();
+ /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */
+ let quizCarryJoinSettingsSnap = null;
+
+ /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */
+ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
+
+ /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */
+ let jumpSurviveCamCenterX = 0;
+ let jumpSurviveCamCenterY = 0;
+ let jumpSurviveEliminated = false;
+ /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
+ let jumpSurviveSessionStartMs = 0;
+ let jumpSurvivePlatformScrollPx = 0;
+ let jumpSurviveVy = 0;
+ let jumpSurviveLastTickMs = 0;
+ let jumpSurviveJumpQueued = false;
+ let jumpSurviveOnGround = false;
+ let jumpSurviveLastEmitT = 0;
+ /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */
+ let jumpSurviveMissionPhase = null;
+ let jumpSurviveGameEnded = false;
+ let jumperMissionCountdownTimer = null;
+
+ /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
+ let spaceShooterBullets = [];
+ let spaceShooterAsteroids = [];
+ /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */
+ let spaceShooterAsteroidExplosions = [];
+ let spaceShooterPopups = [];
+ let spaceShooterLastTickMs = 0;
+ let spaceShooterSpawnAccMs = 0;
+ let spaceShooterFireCd = 0;
+ let spaceShooterSessionStartMs = 0;
+ let spaceShooterLastMoveEmit = 0;
+ let spaceShooterGameEnded = false;
+ let spaceShooterMissionPhase = null;
+ let spaceShooterMissionCountdownTimer = null;
+
+ /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */
+ let quizQuestionMissionPhase = null;
+ let quizQuestionMissionCountdownTimer = null;
+ let quizQuestionMissionDeferredPhase = null;
+ /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */
+ let quizQuestionMissionJoyVecX = 0;
+ let quizQuestionMissionJoyVecY = 0;
+ let quizQuestionMissionJoyPointerId = null;
+ /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */
+ let stackTowerMissionPhase = null;
+ let stackTowerMissionCountdownTimer = null;
+ let stackTowerMissionDeferredPhase = null;
+ let stackTowerSessionStartAt = 0;
+ let stackTowerMissionEndedOnce = false;
+ /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */
+ let stackTowerPost50AnimStartMs = null;
+ /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */
+ let stackTowerResultFlashTimer = null;
+ const STACK_TOWER_RESULT_FLASH_MS = 5000;
+
+ /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */
+ function spaceShooterAsteroidLiveSpriteIndexPlay(a) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || !urls.length) return 0;
+ const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp);
+ if (hp >= maxHp) return 0;
+ if (urls.length >= 3) return 1;
+ return 0;
+ }
+
+ /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */
+ function spaceShooterAsteroidExplosionStartFiPlay() {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return 0;
+ return urls.length >= 3 ? 1 : 0;
+ }
+
+ function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return;
+ spaceShooterAsteroidExplosions.push({
+ x: worldX,
+ y: worldY,
+ r: Math.max(6, Number(r) || 14),
+ fi: spaceShooterAsteroidExplosionStartFiPlay(),
+ acc: 0,
+ });
+ }
+
+ function spaceShooterTickAsteroidExplosions(dt) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) {
+ spaceShooterAsteroidExplosions.length = 0;
+ return;
+ }
+ const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70));
+ const maxFi = urls.length - 2;
+ for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) {
+ const ex = spaceShooterAsteroidExplosions[i];
+ ex.acc += dt * 1000;
+ while (ex.acc >= fms) {
+ ex.acc -= fms;
+ ex.fi++;
+ if (ex.fi > maxFi) {
+ spaceShooterAsteroidExplosions.splice(i, 1);
+ break;
+ }
+ }
+ }
+ }
+
+ /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
+ let balloonBossPendingShots = [];
+ let balloonBossPlayerBullets = [];
+ let balloonBossBossBullets = [];
+ let balloonBossSessionStartMs = 0;
+ let balloonBossLastTickMs = 0;
+ let balloonBossLastMoveEmit = 0;
+ let balloonBossPlayerFireCd = 0;
+ let balloonBossGameEnded = false;
+ let balloonBossHitFx = [];
+ /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */
+ let balloonBossScorePopups = [];
+ let balloonBossBossFireAcc = 0;
+ /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
+ let quizBattleMcqPool = [];
+ let quizBattleAnsweredComps = new Set();
+ let quizBattleModalCompId = null;
+ let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง)
+ /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */
+ let quizBattleAnswerLog = new Map();
+ /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */
+ let quizBattleSessionStartMs = 0;
+ let quizBattleScoreSubmittedAt = 0;
+ /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */
+ let quizBattleChoiceLocked = false;
+ /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */
+ let quizBattleCompletedShown = false;
+ /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */
+ let quizBattleFinishLock = false;
+ /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */
+ let quizBattlePassedComps = new Set();
+ /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */
+ let quizBattleUserZoomMul = 1;
+ const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
+
+ document.getElementById('room-id').textContent = spaceId;
+
+ function hidePlayQuizHud() {
+ const sb = document.getElementById('play-quiz-scoreboard');
+ if (sb) sb.classList.add('is-hidden');
+ const fb = document.getElementById('play-quiz-feedback');
+ if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
+ }
+
+ function renderPlayQuizScoreboard(scores) {
+ const wrap = document.getElementById('play-quiz-scoreboard');
+ const ul = document.getElementById('play-quiz-scoreboard-list');
+ if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return;
+ if (!scores || typeof scores !== 'object') return;
+ wrap.classList.remove('is-hidden');
+ ul.textContent = '';
+ const merged = { ...scores };
+ others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
+ if (myId != null && merged[myId] == null) merged[myId] = 0;
+ const rows = [];
+ if (myId != null) {
+ rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
+ }
+ others.forEach((o, id) => {
+ rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
+ });
+ rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
+ rows.forEach((row) => {
+ const li = document.createElement('li');
+ if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
+ const spN = document.createElement('span');
+ spN.className = 'play-quiz-scoreboard-name';
+ spN.textContent = row.nick;
+ const spV = document.createElement('span');
+ spV.className = 'play-quiz-scoreboard-val';
+ spV.textContent = String(row.sc);
+ li.appendChild(spN);
+ li.appendChild(spV);
+ ul.appendChild(li);
+ });
+ if (isQuizCarry() && useCyberPlayHud()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */
+ if (isQuizQuestionMissionUiMapPlay()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */
+ if (isQuizBattle()) {
+ wrap.classList.add('is-hidden');
+ }
+ }
+
+ function initPlayLiveQuizScoresZeros() {
+ if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return;
+ playLiveQuizScores = {};
+ playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (isQuiz()) playQuizEverWrong = {};
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ function showPlayQuizFeedback(r) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el || !r || !r.results || myId == null) return;
+ let mine = null;
+ let botRight = 0, botWrong = 0;
+ for (let i = 0; i < r.results.length; i++) {
+ const row = r.results[i];
+ if (row.id === myId) mine = row;
+ else if (isPreviewBotId(row.id)) {
+ if (row.right) botRight++;
+ else botWrong++;
+ }
+ }
+ if (!mine) return;
+ el.classList.remove('is-hidden');
+ const botExtra = playBotsEnabled() && (botRight + botWrong > 0)
+ ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
+ : '';
+ if (mine.right) {
+ el.className = 'play-quiz-feedback play-quiz-feedback-ok';
+ el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra;
+ } else {
+ el.className = 'play-quiz-feedback play-quiz-feedback-bad';
+ el.textContent = (mine.choice == null
+ ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด'
+ : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra;
+ }
+ if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT);
+ window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200);
+ }
+
+ function ensureQuizTfScorePlusImgPlay() {
+ if (quizTfScorePlusImg && quizTfScorePlusImg.src) return;
+ quizTfScorePlusImg = new Image();
+ quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL;
+ quizTfScorePlusImg.onerror = function () {
+ this.onerror = null;
+ };
+ }
+
+ /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */
+ function spawnQuizTrueFalseScorePopupPlay(correctTrue) {
+ if (!isQuiz() || !mapData) return;
+ const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea;
+ const b = getTileBoundsForOnesGrid(grid);
+ if (!b) return;
+ const ts = tileSize || 32;
+ const wx = ((b.minX + b.maxX + 1) / 2) * ts;
+ const wy = ((b.minY + b.maxY + 1) / 2) * ts;
+ const now = Date.now();
+ ensureQuizTfScorePlusImgPlay();
+ quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS });
+ }
+
+ function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!quizTfScorePopups.length) return;
+ const img = quizTfScorePlusImg;
+ if (!img || !img.complete || !img.naturalWidth) return;
+ const now = typeof timeMs === 'number' ? timeMs : Date.now();
+ quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now);
+ const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT);
+ for (let i = 0; i < quizTfScorePopups.length; i++) {
+ const p = quizTfScorePopups[i];
+ const span = Math.max(1, p.untilMs - p.startMs);
+ const t = Math.max(0, Math.min(1, (now - p.startMs) / span));
+ const [sx, sy] = worldToScreen(p.wx, p.wy);
+ const rise = t * 56 * zDraw;
+ const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28);
+ const pulse = 1 + Math.sin(t * Math.PI) * 0.1;
+ const scale = (baseW / img.naturalWidth) * pulse;
+ const w = img.naturalWidth * scale;
+ const h = img.naturalHeight * scale;
+ ctx.save();
+ ctx.globalAlpha = fade;
+ ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h);
+ ctx.restore();
+ }
+ }
+
+ function getTileBoundsForOnesGrid(grid) {
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ function getQuizQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */
+ function getQuizCarryQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ function clearQuizMapPanelThemeInline(panel, textEl) {
+ if (!panel || !textEl) return;
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ const qKick = document.getElementById('quiz-map-question-kicker');
+ if (qKick) {
+ qKick.textContent = '';
+ qKick.classList.add('is-hidden');
+ qKick.setAttribute('aria-hidden', 'true');
+ }
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ panel.style.removeProperty('--qmap-bg');
+ panel.style.removeProperty('--qmap-border');
+ panel.style.removeProperty('--qmap-border-w');
+ panel.style.removeProperty('--qmap-shadow');
+ panel.style.removeProperty('background');
+ panel.style.removeProperty('border');
+ panel.style.removeProperty('border-color');
+ panel.style.removeProperty('border-width');
+ panel.style.removeProperty('border-style');
+ panel.style.removeProperty('box-shadow');
+ textEl.style.removeProperty('--qmap-text');
+ textEl.style.removeProperty('--qmap-text-shadow');
+ textEl.style.removeProperty('color');
+ textEl.style.removeProperty('text-shadow');
+ textEl.style.removeProperty('font-size');
+ textEl.style.removeProperty('line-height');
+ textEl.style.removeProperty('width');
+ textEl.style.removeProperty('box-sizing');
+ textEl.style.removeProperty('max-height');
+ textEl.style.removeProperty('overflow');
+ }
+
+ function clearQuizMapQuestionCarryFeedback() {
+ if (typeof window.__quizMapCarryFeedbackT === 'number') {
+ clearTimeout(window.__quizMapCarryFeedbackT);
+ window.__quizMapCarryFeedbackT = null;
+ }
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (icon) {
+ icon.classList.add('is-hidden');
+ icon.removeAttribute('src');
+ }
+ if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */
+ function showQuizMapQuestionCarryFeedback(isCorrect) {
+ if (!isQuizCarry()) return;
+ const panel = document.getElementById('quiz-map-question-panel');
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (!panel || !icon || panel.classList.contains('is-hidden')) return;
+ clearQuizMapQuestionCarryFeedback();
+ icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
+ icon.classList.remove('is-hidden');
+ if (isCorrect && scoreWrap && scoreEl) {
+ scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus;
+ scoreWrap.classList.remove('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'false');
+ scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ void scoreEl.offsetWidth;
+ scoreEl.classList.add('quiz-map-q-feedback-score--pop');
+ } else if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ window.__quizMapCarryFeedbackT = setTimeout(() => {
+ window.__quizMapCarryFeedbackT = null;
+ clearQuizMapQuestionCarryFeedback();
+ }, QUIZ_MAP_CARRY_FEEDBACK_MS);
+ }
+
+ /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */
+ function defaultCarryMapPanelThemePlay() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+ }
+
+ /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */
+ function quizCarryPlaqueMapScaleClampedPlay() {
+ const sc = Number(quizCarryPlaqueMapScale);
+ if (!Number.isFinite(sc)) return 1.25;
+ return Math.max(0.85, Math.min(2.5, sc));
+ }
+
+ /**
+ * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps))
+ * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max)
+ */
+ function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) {
+ const ts = tileSize * zoomVal;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const base = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const fb = defaultCarryMapPanelThemePlay();
+ let mn = Number(th && th.questionFontMinPx);
+ let mx = Number(th && th.questionFontMaxPx);
+ if (!Number.isFinite(mn)) mn = fb.questionFontMinPx;
+ if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx;
+ mn = Math.round(Math.max(8, Math.min(40, mn)));
+ mx = Math.round(Math.max(10, Math.min(72, mx)));
+ if (mx < mn) {
+ const s = mn;
+ mn = mx;
+ mx = s;
+ }
+ return Math.max(mn, Math.min(mx, base));
+ }
+
+ /**
+ * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar)
+ * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด
+ * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน
+ */
+ function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) {
+ const cs = window.getComputedStyle(panel);
+ const padT = parseFloat(cs.paddingTop) || 0;
+ const padB = parseFloat(cs.paddingBottom) || 0;
+ const padL = parseFloat(cs.paddingLeft) || 0;
+ const padR = parseFloat(cs.paddingRight) || 0;
+ let kickerReserve = 0;
+ if (panel.classList.contains('quiz-map-question-panel--question-mission-live')
+ && panel.classList.contains('quiz-map-question-panel--has-kicker')) {
+ kickerReserve = 30;
+ }
+ const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve);
+ const availW = Math.max(24, panel.clientWidth - padL - padR);
+ const themeMin = Math.max(8, Math.floor(Number(minPx) || 8));
+ const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7)));
+ let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin)));
+ textEl.style.setProperty('line-height', '1.28', 'important');
+ textEl.style.setProperty('width', '100%', 'important');
+ textEl.style.setProperty('box-sizing', 'border-box', 'important');
+ textEl.style.removeProperty('max-height');
+ textEl.style.setProperty('overflow', 'hidden', 'important');
+ for (let i = 0; i < 96 && px > hardMin; i++) {
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ const sh = textEl.scrollHeight;
+ const sw = textEl.scrollWidth;
+ if (sh <= availH + 2 && sw <= availW + 2) break;
+ px -= 1;
+ }
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ textEl.style.setProperty('max-height', availH + 'px', 'important');
+ }
+
+ /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */
+ function parseCarryMapPanelThemeObject(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = Number(t.questionFontMinPx);
+ let qMax = Number(t.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ return {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+ }
+
+ /**
+ * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง
+ * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24)
+ */
+ function parseCarryMapPanelThemeMapOverlay(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const out = {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ };
+ const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx');
+ const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx');
+ if (hasMin || hasMax) {
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx;
+ let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx;
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ out.questionFontMinPx = qMin;
+ out.questionFontMaxPx = qMax;
+ }
+ return out;
+ }
+
+ function setQuizCarryMapPanelThemeFromApi(s) {
+ quizCarryMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme);
+ if (!parsed) return;
+ quizCarryMapPanelTheme = parsed;
+ }
+
+ function setQuizMapPanelThemeFromApi(s) {
+ quizMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme);
+ if (!parsed) return;
+ quizMapPanelTheme = parsed;
+ }
+
+ function defaultCarryChoicePlaqueThemePlay() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ plaqueImageUrl: '',
+ };
+ }
+
+ function parseCarryChoicePlaqueThemeObject(raw) {
+ const d = defaultCarryChoicePlaqueThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const safeColor = (x, fb) => {
+ const t = String(x == null ? '' : x).trim().slice(0, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder),
+ fillBg: safeColor(raw.fillBg, d.fillBg),
+ textColor: safeColor(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl),
+ };
+ }
+
+ function buildCarryChoicePlaqueThemesArrayFromApi(s) {
+ if (!s || typeof s !== 'object') return null;
+ if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) {
+ out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i]));
+ }
+ return out;
+ }
+ if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') {
+ const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme);
+ return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one }));
+ }
+ return null;
+ }
+
+ function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) {
+ const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0));
+ if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) {
+ return quizCarryChoicePlaqueThemes[i];
+ }
+ return defaultCarryChoicePlaqueThemePlay();
+ }
+
+ function setQuizCarryChoicePlaqueThemeFromApi(s) {
+ quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s);
+ }
+
+ function sanitizeQuizCarryImageUrlClient(u) {
+ const s = String(u == null ? '' : u).trim().slice(0, 512);
+ if (!s || /[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const parsed = new URL(s);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+ }
+
+ /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */
+ function quizCarryApplyImageCrossOrigin(img, rawUrl) {
+ if (!img) return;
+ try {
+ img.removeAttribute('crossorigin');
+ const u = sanitizeQuizCarryImageUrlClient(rawUrl);
+ if (!u || u.startsWith('data:')) return;
+ let imgOrigin = '';
+ if (u.startsWith('http://') || u.startsWith('https://')) {
+ imgOrigin = new URL(u).origin;
+ } else if (u.startsWith('/')) {
+ imgOrigin = window.location.origin;
+ }
+ if (imgOrigin && imgOrigin !== window.location.origin) {
+ img.crossOrigin = 'anonymous';
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function preloadQuizCarryChoiceImages(q) {
+ if (!q) return;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ for (let i = 0; i < q.choiceImageUrls.length; i++) {
+ const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]);
+ if (!raw) continue;
+ if (quizCarryChoiceImageCache.has(raw)) continue;
+ const im = new Image();
+ quizCarryApplyImageCrossOrigin(im, raw);
+ im.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw, im);
+ try {
+ im.src = raw;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ if (Array.isArray(q.choices)) {
+ for (let j = 0; j < q.choices.length; j++) {
+ const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j);
+ if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue;
+ const im2 = new Image();
+ quizCarryApplyImageCrossOrigin(im2, raw2);
+ im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw2, im2);
+ try {
+ im2.src = raw2;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ }
+
+ function getQuizCarryChoiceImageCached(url) {
+ const u = sanitizeQuizCarryImageUrlClient(url);
+ if (!u) return null;
+ const im = quizCarryChoiceImageCache.get(u);
+ if (im && im.complete && im.naturalWidth > 0) return im;
+ return null;
+ }
+
+ function getQuizCarryChoiceImageUrlForIndex(q, idx) {
+ if (!q || !Array.isArray(q.choiceImageUrls)) return '';
+ const u = q.choiceImageUrls[idx];
+ return sanitizeQuizCarryImageUrlClient(u);
+ }
+
+ /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */
+ function getQuizCarryPlaqueImageUrlForIndex(q, idx) {
+ const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx);
+ if (perQ) return perQ;
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx);
+ const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : '';
+ return u || '';
+ }
+
+ function defaultCarryEmbedCountdownThemePlay() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+ }
+
+ function parseCarryEmbedCountdownThemeObject(raw) {
+ const d = defaultCarryEmbedCountdownThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ const clipStr = (x, maxLen) => {
+ const t = String(x == null ? '' : x).trim().slice(0, maxLen);
+ return t || null;
+ };
+ const safeColor = (x, fb) => {
+ const t = clipStr(x, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ return {
+ overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: safeColor(raw.innerBg, d.innerBg),
+ innerBorder: safeColor(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: safeColor(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+ }
+
+ function setQuizCarryEmbedCountdownThemeFromApi(s) {
+ quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme);
+ applyQuizCarryEmbedCountdownThemeToDom();
+ }
+
+ const CARRY_ECD_VAR_KEYS = [
+ '--carry-ecd-overlay',
+ '--carry-ecd-inner-bg',
+ '--carry-ecd-inner-border',
+ '--carry-ecd-inner-border-w',
+ '--carry-ecd-inner-radius',
+ '--carry-ecd-digit-color',
+ '--carry-ecd-map-digit-fs',
+ '--carry-ecd-screen-vw',
+ '--carry-ecd-screen-max',
+ ];
+
+ function clearQuizCarryEmbedCountdownThemeDomVars() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ if (!root) return;
+ CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } });
+ }
+
+ /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */
+ function applyQuizCarryEmbedCountdownThemeToDom() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+ if (!root) return;
+ clearQuizCarryEmbedCountdownThemeDomVars();
+ const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay();
+ root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop);
+ root.style.setProperty('--carry-ecd-inner-bg', th.innerBg);
+ root.style.setProperty('--carry-ecd-inner-border', th.innerBorder);
+ root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`);
+ root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`);
+ root.style.setProperty('--carry-ecd-digit-color', th.digitColor);
+ const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78)));
+ const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82)));
+ const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200)));
+ root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`);
+ const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28)));
+ const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132)));
+ root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`);
+ root.style.setProperty('--carry-ecd-screen-max', `${smx}px`);
+ if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') {
+ numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)';
+ } else if (numEl) {
+ try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ }
+ }
+ }
+
+ function setCountdown321QuestionAssetGraphic(numEl, n) {
+ if (!numEl) return;
+ const d = Math.max(1, Math.min(3, n));
+ if (String(numEl.tagName || '').toUpperCase() === 'IMG') {
+ numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png';
+ numEl.alt = String(d);
+ } else {
+ numEl.textContent = String(d);
+ }
+ }
+
+ /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */
+ function getEffectiveCarryMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.carryMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */
+ function getEffectiveQuizMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.quizMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ function paintQuizMapPanelTheme(panel, textEl, th) {
+ if (!panel || !textEl || !th) return;
+ clearQuizMapPanelThemeInline(panel, textEl);
+ const bgStr = th.panelBg != null ? String(th.panelBg).trim() : '';
+ const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : '';
+ const txStr = th.textColor != null ? String(th.textColor).trim() : '';
+ const wRaw = Number(th.borderWidthPx);
+ const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0;
+ const bgUse = bgStr || 'transparent';
+ const brUse = brStr || 'transparent';
+ const txUse = txStr || '#f1f5f9';
+ panel.style.setProperty('--qmap-bg', bgUse);
+ panel.style.setProperty('--qmap-border', brUse);
+ panel.style.setProperty('--qmap-border-w', String(w) + 'px');
+ panel.style.setProperty('--qmap-shadow', 'none');
+ textEl.style.setProperty('--qmap-text', txUse);
+ textEl.style.setProperty('--qmap-text-shadow', 'none');
+ /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */
+ panel.style.setProperty('background', bgUse, 'important');
+ panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important');
+ panel.style.setProperty('box-shadow', 'none', 'important');
+ textEl.style.setProperty('color', txUse, 'important');
+ textEl.style.setProperty('text-shadow', 'none', 'important');
+ }
+
+ function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuizCarry()) {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply());
+ }
+
+ function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuiz() || isQuizCarry()) return;
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply());
+ }
+
+ function syncPlayQuizMapPanel() {
+ const panel = document.getElementById('quiz-map-question-panel');
+ const textEl = document.getElementById('quiz-map-question-text');
+ if (!panel || !textEl || !mapData) return;
+ /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */
+ if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (isQuizBattle()) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (!isQuiz() && !isQuizCarry()) {
+ clearQuizMapQuestionCarryFeedback();
+ return;
+ }
+ const bounds = isQuiz()
+ ? getQuizQuestionAreaTileBounds(mapData)
+ : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData));
+ const text = (playQuizText || '').trim();
+ if (!bounds || !text) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ textEl.textContent = text;
+ panel.style.left = Math.round(left) + 'px';
+ panel.style.top = Math.round(top) + 'px';
+ panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ panel.classList.remove('is-hidden');
+ panel.setAttribute('aria-hidden', 'false');
+ if (isQuiz() && !isQuizCarry()) {
+ panel.classList.add('quiz-map-question-panel--true-false');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--true-false');
+ }
+ if (isQuizCarry()) {
+ applyQuizCarryMapPanelThemeIfNeeded(panel, textEl);
+ } else if (isQuiz()) {
+ applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl);
+ } else {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ }
+ const thFont = isQuizCarry()
+ ? getEffectiveCarryMapPanelThemeForApply()
+ : getEffectiveQuizMapPanelThemeForApply();
+ const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom);
+ const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10));
+ /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') {
+ panel.classList.add('quiz-map-question-panel--question-mission-live');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ if (panel.dataset.qmPlaqueTry) {
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ }
+ }
+ panel.classList.toggle(
+ 'quiz-map-question-panel--tf-mock-qz',
+ !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()),
+ );
+ const kickerEl = document.getElementById('quiz-map-question-kicker');
+ const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer';
+ if (kickerEl) {
+ let showK = false;
+ let line = '';
+ if (missionLiveQ && inReadOrAnswer) {
+ const qi = Math.max(0, Number(playQuizQuestionIndex) || 0);
+ const qt = Math.max(0, Number(playQuizQuestionTotal) || 0);
+ if (qi > 0 && qt > 0) {
+ line = '- Quiz ' + qi + ' / ' + qt + ' -';
+ showK = true;
+ } else if (qi > 0) {
+ line = '- Quiz ' + qi + ' -';
+ showK = true;
+ }
+ }
+ if (showK && line) {
+ kickerEl.textContent = line;
+ kickerEl.classList.remove('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'false');
+ panel.classList.add('quiz-map-question-panel--has-kicker');
+ } else {
+ kickerEl.textContent = '';
+ kickerEl.classList.add('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'true');
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ }
+ }
+ fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont);
+ }
+
+ /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */
+ function syncQuizCarryTimeupDeskLayerPosition() {
+ const layer = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!layer || layer.classList.contains('is-hidden')) return;
+ const anchor = layer.querySelector('.qc-timeup-desk-anchor')
+ || layer.querySelector('.qc-timeup-desk-inner')
+ || (() => {
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null;
+ })();
+ if (!anchor || !canvas) return;
+ const clearToFullStack = () => {
+ anchor.style.left = '0';
+ anchor.style.top = '0';
+ anchor.style.width = '100%';
+ anchor.style.height = '100%';
+ };
+ const stack = document.getElementById('play-canvas-stack');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (stack && panel && !panel.classList.contains('is-hidden')) {
+ try {
+ const sr = stack.getBoundingClientRect();
+ const pr = panel.getBoundingClientRect();
+ const rw = pr.width;
+ const rh = pr.height;
+ if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) {
+ anchor.style.left = Math.round(pr.left - sr.left) + 'px';
+ anchor.style.top = Math.round(pr.top - sr.top) + 'px';
+ anchor.style.width = Math.round(rw) + 'px';
+ anchor.style.height = Math.round(rh) + 'px';
+ return;
+ }
+ } catch (e) { /* fallback ด้านล่าง */ }
+ }
+ const pl = panel && panel.style && String(panel.style.left || '').trim();
+ const pt = panel && panel.style && String(panel.style.top || '').trim();
+ const pw = panel && panel.style && String(panel.style.width || '').trim();
+ const ph = panel && panel.style && String(panel.style.height || '').trim();
+ if (pl && pt && pw && ph) {
+ anchor.style.left = pl;
+ anchor.style.top = pt;
+ anchor.style.width = pw;
+ anchor.style.height = ph;
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'quiz_carry') {
+ clearToFullStack();
+ return;
+ }
+ const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ clearToFullStack();
+ return;
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ anchor.style.left = Math.round(left) + 'px';
+ anchor.style.top = Math.round(top) + 'px';
+ anchor.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ anchor.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ }
+
+ function carryEmbedCountdownAnchorResolved() {
+ if (!mapData || !isQuizCarry()) return 'screen';
+ const v = mapData.carryEmbedCountdownAnchor;
+ if (v === 'grid' || v === 'map') return v;
+ return 'screen';
+ }
+
+ function resetQuizCarryEmbedCountdownLayoutDom() {
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor');
+ if (inner) {
+ inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = '';
+ inner.style.left = '';
+ inner.style.top = '';
+ inner.style.width = '';
+ inner.style.height = '';
+ inner.style.transform = '';
+ inner.style.maxWidth = '';
+ inner.style.boxSizing = '';
+ inner.style.display = '';
+ inner.style.margin = '';
+ }
+ }
+
+ /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */
+ function syncQuizCarryEmbedCountdownLayout() {
+ if (!quizCarryEmbedMissionFlowActive() || !mapData) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ const canvasEl = document.getElementById('game-canvas');
+ if (!ov || !inner || !canvasEl) return;
+ if (ov.classList.contains('is-hidden')) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const anchor = carryEmbedCountdownAnchorResolved();
+ if (anchor === 'screen') {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ let bounds = null;
+ if (anchor === 'grid') {
+ bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ } else {
+ bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2;
+ const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ const r = canvasEl.getBoundingClientRect();
+ const sl = r.left + l;
+ const st = r.top + t;
+ ov.classList.add('quiz-carry-embed-countdown--map-anchor');
+ inner.classList.add('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = 'fixed';
+ inner.style.left = Math.round(sl) + 'px';
+ inner.style.top = Math.round(st) + 'px';
+ inner.style.width = Math.round(Math.max(100, wPx)) + 'px';
+ inner.style.height = Math.round(Math.max(72, hPx)) + 'px';
+ inner.style.transform = 'none';
+ inner.style.boxSizing = 'border-box';
+ inner.style.display = 'flex';
+ inner.style.flexDirection = 'column';
+ inner.style.alignItems = 'center';
+ inner.style.justifyContent = 'center';
+ inner.style.margin = '0';
+ inner.style.maxWidth = 'none';
+ }
+
+ /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
+ function syncQuizCarryEmbedQuestionStrip() {
+ const wrap = document.getElementById('quiz-carry-embed-q-strip');
+ const p = document.getElementById('quiz-carry-embed-q-strip-text');
+ if (!wrap || !p) return;
+ wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
+ wrap.classList.add('is-hidden');
+ wrap.setAttribute('aria-hidden', 'true');
+ }
+
+ function updatePlayQuizTimerDisplay() {
+ const el = document.getElementById('quiz-game-timer');
+ if (!el) return;
+ if (!playQuizPhaseEndsAt) {
+ el.textContent = '';
+ return;
+ }
+ const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
+ el.textContent = s + ' วินาที';
+ }
+
+ function clampPreviewMs(n, def, minV, maxV) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(minV, Math.min(maxV, Math.floor(v)));
+ }
+
+ function clampCarrySessionLen(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+ }
+
+ function buildQuizPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ return md.quizQuestions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+
+ function shufflePreviewQuizQuestionsPlay(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;
+ }
+
+ /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */
+ function clampPreviewQuizRoundQuestionCount(settings) {
+ const v = Number(settings && settings.quizRoundQuestionCount);
+ if (!Number.isFinite(v)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(v)));
+ }
+
+ function finishPreviewQuizSessionPlay() {
+ previewQuizStep = 'done';
+ playQuizPhaseLocal = null;
+ previewQuizCurrent = null;
+ playQuizPhaseEndsAt = 0;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) qov.classList.add('is-hidden');
+ const mapPanel = document.getElementById('quiz-map-question-panel');
+ if (mapPanel) {
+ mapPanel.classList.add('is-hidden');
+ mapPanel.setAttribute('aria-hidden', 'true');
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = 'ended';
+ applyQuizQuestionMissionPanelImages();
+ const mission = quizQuestionMissionBuildPayload();
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว';
+ if (qov) qov.classList.remove('is-hidden');
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)';
+ }
+
+ function clearPreviewBotAnswerPaths() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function resetPreviewBotsQuizState() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.quizCannotTrue = false;
+ o.quizCannotFalse = false;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function applyPreviewReadPhase(q, poolLen) {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'read';
+ playQuizPhaseLocal = 'read';
+ previewQuizCurrent = q;
+ playQuizText = q.text;
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs;
+ playQuizQuestionIndex = previewQuizQIndex + 1;
+ playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) {
+ phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen;
+ }
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ';
+ }
+
+ function applyPreviewAnswerPhase() {
+ previewQuizStep = 'answer';
+ playQuizPhaseLocal = 'answer';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg && previewQuizCurrent) {
+ leg.textContent = previewQuizCurrent.answerTrue
+ ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า'
+ : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู';
+ }
+ previewBotsPrepareAnswerRound();
+ }
+
+ function applyPreviewBetweenPhase() {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'between';
+ playQuizPhaseLocal = null;
+ playQuizText = 'ข้อถัดไปกำลังจะมา…';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)';
+ }
+
+ function quizCellOnPlay(grid, x, y) {
+ return !!(grid && grid[y] && grid[y][x] === 1);
+ }
+
+ /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */
+ function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) {
+ if (!mapData || !isQuiz()) return false;
+ for (const k of quizTilesFootprintPlay(ax, ay)) {
+ const parts = k.split(',');
+ const fx = +parts[0];
+ const fy = +parts[1];
+ if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true;
+ }
+ return false;
+ }
+
+ /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */
+ function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) {
+ if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false;
+ if (entId === myId || ent === me) {
+ return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse);
+ }
+ const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse);
+ const everWrong = !!playQuizEverWrong[String(entId)];
+ return locked || everWrong;
+ }
+
+ function spawnTileWalkablePlay(md, x, y) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ if (row && row[x] === 1) return false;
+ return true;
+ }
+
+ function spawnFootprintFitsPlay(md, anchorX, anchorY) {
+ if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false;
+ const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1)));
+ const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1)));
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const maxX = Math.min(w, anchorX + cellsW);
+ const maxY = Math.min(h, anchorY + cellsH);
+ for (let ty = anchorY; ty < maxY; ty++) {
+ for (let tx = anchorX; tx < maxX; tx++) {
+ if (!spawnTileWalkablePlay(md, tx, ty)) return false;
+ }
+ }
+ return true;
+ }
+
+ /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */
+ function pickRandomSpawnFromMapPlay(md) {
+ const fallback = md.spawn || { x: 1, y: 1 };
+ const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
+ const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return { x: fx, y: fy };
+ const u = new Uint32Array(1);
+ (typeof crypto !== 'undefined' && crypto.getRandomValues)
+ ? crypto.getRandomValues(u)
+ : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0));
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function parseLobbyPlayerSpawnsFromMapPlay(md) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const out = [null, null, null, null, null, null];
+ const raw = md && md.lobbyPlayerSpawns;
+ if (!Array.isArray(raw)) return out;
+ for (let i = 0; i < 6 && i < raw.length; i++) {
+ const cell = raw[i];
+ if (!cell || typeof cell !== 'object') continue;
+ const x = Math.floor(Number(cell.x));
+ const y = Math.floor(Number(cell.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+ if (x < 0 || x >= w || y < 0 || y >= h) continue;
+ out[i] = { x, y };
+ }
+ return out;
+ }
+
+ /** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */
+ function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) {
+ if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
+ const g = md.shooterSpawnSlots;
+ if (!g) return;
+ const w = md.width || 20, h = md.height || 15;
+ for (let slot = 1; slot <= 6; slot++) {
+ const idx = slot - 1;
+ if (slots6[idx]) continue;
+ let found = false;
+ for (let yy = 0; yy < h && !found; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] === slot) {
+ slots6[idx] = { x: xx, y: yy };
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */
+ function collectQuizBattleValidSpawnCentersPlay(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+ }
+
+ /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */
+ function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersPlay(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */
+ function collectQuizBattleSpawnPoolPlay(md) {
+ const out = [];
+ if (!md) return out;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActive(md)) {
+ if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue;
+ } else if (!spawnFootprintFitsPlay(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActive(md)) {
+ return collectQuizBattleValidSpawnCentersPlay(md);
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ return out;
+ }
+
+ function pickQuizBattleRandomSpawnWorldPlay(md) {
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y);
+ }
+ const u = new Uint32Array(1);
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u);
+ else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0);
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldPlay(md, sx, sy);
+ }
+ /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+
+ function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+ }
+
+ /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */
+ function pickSpawnForJoinPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1, y: 1 };
+ if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) {
+ const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex);
+ if (qb) return qb;
+ }
+ const mode = md.lobbySpawnMode;
+ const ord = joinOrderIndex | 0;
+ if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ if (mode === 'fixed' && md.spawn) {
+ const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
+ const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const x = Math.max(0, Math.min(w - 1, fx));
+ const y = Math.max(0, Math.min(h - 1, fy));
+ if (spawnFootprintFitsPlay(md, x, y)) return { x, y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots);
+ const pick = slots[j];
+ if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ return pickRandomSpawnFromMapPlay(md);
+ }
+
+ const GAUNTLET_PREVIEW_MAX = 6;
+ function gauntletSpawnYsPlay(md, playerCount) {
+ const h = md.height || 15;
+ const lo = 1;
+ const hi = Math.max(lo, h - 2);
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ if (hi <= lo) return Array.from({ length: n }, () => lo);
+ const ys = [];
+ for (let i = 0; i < n; i++) {
+ ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
+ }
+ return ys;
+ }
+ function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return [];
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
+ }
+ }
+ slots.sort((a, b) => a.y - b.y || a.x - b.x);
+ return slots;
+ }
+ function collectGauntletSpawnSlotsPlay(md) {
+ const explicit = md.gauntletPlayerSpawns;
+ if (Array.isArray(explicit) && explicit.length > 0) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (const raw of explicit) {
+ if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
+ const x = Math.floor(Number(raw && raw.x));
+ const y = Math.floor(Number(raw && raw.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
+ if (!spawnTileWalkablePlay(md, x, y)) continue;
+ slots.push({ x, y });
+ }
+ if (slots.length > 0) return slots;
+ }
+ return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
+ }
+ function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
+ const w = md.width || 20;
+ if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
+ if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
+ for (let d = 0; d < w; d++) {
+ if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
+ if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
+ }
+ return Math.max(0, Math.min(w - 1, spawnColX));
+ }
+ function gauntletSpawnPositionsPlay(md, playerCount) {
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ const slots = collectGauntletSpawnSlotsPlay(md);
+ const fallbackYs = gauntletSpawnYsPlay(md, n);
+ const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const y = i < slots.length
+ ? slots[i].y
+ : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
+ const slotX = i < slots.length ? slots[i].x : null;
+ const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
+ out.push({ x, y });
+ }
+ return out;
+ }
+ /**
+ * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
+ * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
+ */
+ function applyGauntletPreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ const humanCount = 1 + realIds.length;
+ const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
+ const pos = gauntletSpawnPositionsPlay(mapData, total);
+ if (onlyBots) {
+ let idx = humanCount;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ return;
+ }
+ let idx = 0;
+ if (idx < pos.length) {
+ me.x = pos[idx].x;
+ me.y = pos[idx].y;
+ me.tx = me.x;
+ me.ty = me.y;
+ idx++;
+ }
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ }
+
+ function isPreviewBotId(id) {
+ return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
+ }
+
+ function countPlayHumans() {
+ let n = 1;
+ others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
+ return n;
+ }
+
+ function rebalancePreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const human = countPlayHumans();
+ const wantBots = Math.max(0, playBotTargetHeadcount() - human);
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid);
+ if (o.botTier == null) {
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ o.botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ }
+ if (o.quizCannotTrue == null) o.quizCannotTrue = false;
+ if (o.quizCannotFalse == null) o.quizCannotFalse = false;
+ if (!Array.isArray(o.botPath)) o.botPath = [];
+ if (o.botAnswerWander == null) o.botAnswerWander = false;
+ if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ if (o.gauntletScore == null) {
+ o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0;
+ }
+ if (o.gauntletEliminated == null) o.gauntletEliminated = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ o.botWanderDx = wd[0];
+ o.botWanderDy = wd[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') {
+ o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900);
+ }
+ });
+ let botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length > wantBots) {
+ const drop = botIds.pop();
+ if (drop) others.delete(drop);
+ }
+ botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length < wantBots) {
+ /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */
+ let nextSlot = 0;
+ while (others.has(PREVIEW_BOT_PREFIX + nextSlot)) nextSlot++;
+ previewBotSeq = Math.max(previewBotSeq, nextSlot + 1);
+ const id = PREVIEW_BOT_PREFIX + nextSlot;
+ const joinIdx = countPlayHumans() + botIds.length;
+ let x;
+ let y;
+ if (mapData.gameType === 'jump_survive') {
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else {
+ const sp = pickSpawnForJoinPlay(mapData, joinIdx);
+ const jx = (Math.random() - 0.5) * 0.4;
+ const jy = (Math.random() - 0.5) * 0.4;
+ x = sp.x + 0.5 + jx;
+ y = sp.y + 0.5 + jy;
+ }
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ const botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay();
+ others.set(id, {
+ x, y, tx: x, ty: y,
+ direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)],
+ nickname: 'บอท',
+ characterId: pickPreviewBotCharacterId(botIds.length),
+ spawnJoinOrder: joinIdx,
+ playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id),
+ botTier,
+ gauntletJumpTicks: 0,
+ gauntletJumpVis: 0,
+ gauntletScore: crownStart ? 100 : 0,
+ gauntletEliminated: false,
+ quizCannotTrue: false,
+ quizCannotFalse: false,
+ quizCarryHeld: null,
+ botPath: [],
+ botAnswerWander: false,
+ botWanderDx: wd[0],
+ botWanderDy: wd[1],
+ botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: 0,
+ balloonBossScore: 0,
+ balloonBossBossDmg: 0,
+ balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5,
+ balloonBossEliminated: false,
+ botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
+ });
+ botIds.push(id);
+ }
+ let k = 0;
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
+ o.nickname = 'บอท ' + (++k) + ' ' + tag;
+ });
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => {
+ const o = others.get(bid);
+ if (o) o.characterId = pickPreviewBotCharacterId(botIdx);
+ });
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ const t0 = performance.now();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140);
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (mapData.gameType === 'stack') {
+ applyStackPreviewSpawnLayout();
+ rebuildStackPreviewTurnOrder();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(false);
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function pickRandomWalkableCellInAnswerGrid(grid, o) {
+ if (!grid || !mapData) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] !== 1) continue;
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */
+ function previewBotsPrepareAnswerRound() {
+ if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ const tier = o.botTier || 'avg';
+ let pCorrect = 0.52;
+ if (tier === 'sharp') pCorrect = 0.84;
+ else if (tier === 'weak') pCorrect = 0.22;
+ if (Math.random() < 0.14) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const wantsCorrect = Math.random() < pCorrect;
+ const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
+ const zoneGrid = targetTrue ? qt : qf;
+ const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
+ if (!dest) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (!path || path.length <= 1) {
+ o.botAnswerWander = true;
+ return;
+ }
+ o.botPath = path.slice(1);
+ });
+ }
+
+ /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
+ function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const tGrid = md.quizTrueArea || [];
+ const fGrid = md.quizFalseArea || [];
+ const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
+ const walkable = (x, y) => {
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ return row && row[x] !== 1;
+ };
+ const fx = Math.floor(Number(sx));
+ const fy = Math.floor(Number(sy));
+ if (!walkable(fx, fy)) {
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+ if (!inAnswer(fx, fy)) return { x: sx, y: sy };
+ const q = [[fx, fy]];
+ const seen = new Set([`${fx},${fy}`]);
+ const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
+ while (q.length) {
+ const [cx, cy] = q.shift();
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = cx + dirs[di][0];
+ const ny = cy + dirs[di][1];
+ const k = `${nx},${ny}`;
+ if (seen.has(k)) continue;
+ if (!walkable(nx, ny)) continue;
+ seen.add(k);
+ if (!inAnswer(nx, ny)) {
+ return { x: nx + 0.5, y: ny + 0.5 };
+ }
+ q.push([nx, ny]);
+ }
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+
+ function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
+ const tx = Math.floor(ox);
+ const ty = Math.floor(oy);
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ const inT = quizCellOnPlay(qt, tx, ty);
+ const inF = quizCellOnPlay(qf, tx, ty);
+ let choice = null;
+ if (inT && !inF) choice = true;
+ else if (inF && !inT) choice = false;
+ else if (inT && inF) choice = true;
+ const right = choice !== null && choice === correctTrue;
+ return { choice, right };
+ }
+
+ /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */
+ function quizPreviewJoinOrderForRespawn(actorId) {
+ if (actorId === myId) {
+ const jm = Number(me && me.spawnJoinOrder);
+ return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0;
+ }
+ const o = others.get(actorId);
+ if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) {
+ return Math.max(0, Math.floor(o.spawnJoinOrder));
+ }
+ if (!isPreviewBotId(actorId)) {
+ const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const idx = othersReal.indexOf(actorId);
+ return idx >= 0 ? 1 + idx : 1;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const bi = bots.indexOf(actorId);
+ if (bi < 0) return 0;
+ return countPlayHumans() + bi;
+ }
+
+ /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */
+ function resolvePreviewQuizRound() {
+ if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const results = [];
+ const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue);
+ let right = mine.right;
+ let choice = mine.choice;
+ if (mine.right) {
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ playQuizPlayerLocal.cannotTrue = true;
+ playQuizPlayerLocal.cannotFalse = true;
+ if (!warpMeIntoQuizGhostZonePlay()) {
+ const ordMe = quizPreviewJoinOrderForRespawn(myId);
+ const sp = pickSpawnForJoinPlay(mapData, ordMe);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ me.x = pos.x;
+ me.y = pos.y;
+ me.tx = me.x;
+ me.ty = me.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+ results.push({
+ id: myId,
+ right,
+ choice,
+ score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0,
+ });
+ if (playBotsEnabled()) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue);
+ o.botPath = [];
+ o.botAnswerWander = false;
+ if (br.right) {
+ playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ const ordB = quizPreviewJoinOrderForRespawn(id);
+ const sp = pickSpawnForJoinPlay(mapData, ordB);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ o.x = pos.x + (Math.random() - 0.5) * 0.35;
+ o.y = pos.y + (Math.random() - 0.5) * 0.35;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+ results.push({
+ id,
+ right: br.right,
+ choice: br.choice,
+ score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
+ });
+ });
+ }
+ const r = { results, scores: { ...playLiveQuizScores } };
+ results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) playQuizEverWrong[String(row.id)] = true;
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ }
+
+ function advancePreviewQuizIfDue() {
+ if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return;
+ if (previewQuizStep === 'done') return;
+ if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return;
+ const pool = previewQuizPool;
+ if (!pool || !pool.length) return;
+ if (previewQuizStep === 'read') {
+ applyPreviewAnswerPhase();
+ return;
+ }
+ if (previewQuizStep === 'answer') {
+ resolvePreviewQuizRound();
+ applyPreviewBetweenPhase();
+ return;
+ }
+ if (previewQuizStep === 'between') {
+ previewQuizQIndex += 1;
+ if (previewQuizQIndex >= pool.length) {
+ finishPreviewQuizSessionPlay();
+ return;
+ }
+ const q = pool[previewQuizQIndex];
+ if (q) applyPreviewReadPhase(q, pool.length);
+ }
+ }
+
+ async function loadPreviewQuizAndStart() {
+ resetPreviewBotsQuizState();
+ playQuizEverWrong = {};
+ let settings = null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) settings = await r.json();
+ } catch (e) { /* use map fallback */ }
+ let pool = [];
+ if (settings && Array.isArray(settings.questions) && settings.questions.length) {
+ pool = settings.questions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+ if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData);
+ const cap = clampPreviewQuizRoundQuestionCount(settings);
+ const shuffled = shufflePreviewQuizQuestionsPlay(pool);
+ previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length));
+ previewQuizQIndex = 0;
+ const dRead = 10000;
+ const dAns = 5000;
+ const dBet = 3500;
+ previewQuizTiming = {
+ readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000),
+ answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000),
+ betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000),
+ };
+ previewQuizCurrent = null;
+ if (!pool.length) {
+ playQuizPhaseLocal = 'read';
+ previewQuizStep = 'read';
+ playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ';
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ return;
+ }
+ const session = previewQuizPool;
+ const q = session[0];
+ if (q) applyPreviewReadPhase(q, session.length);
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ }
+
+ function startPlayQuizTimer() {
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(() => {
+ updatePlayQuizTimerDisplay();
+ advancePreviewQuizIfDue();
+ }, 200);
+ updatePlayQuizTimerDisplay();
+ }
+
+ function teardownPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ const mapQText = document.getElementById('quiz-map-question-text');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, mapQText);
+ }
+ playQuizPhaseLocal = null;
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ previewQuizPool = [];
+ previewQuizQIndex = 0;
+ previewQuizCurrent = null;
+ previewQuizStep = 'read';
+ playLiveQuizScores = {};
+ playQuizEverWrong = {};
+ quizTfScorePopups = [];
+ playPath = [];
+ hidePlayQuizHud();
+ quizCarryJoinSettingsSnap = null;
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ quizCarryMapPanelTheme = null;
+ quizMapPanelTheme = null;
+ quizCarryChoicePlaqueThemes = null;
+ resetQuizCarryPlayState();
+ resetQuizBattlePlayState();
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmTeardown) gcmTeardown.classList.add('is-hidden');
+ cancelEmbedPreviewLobbyReturnTimer();
+ const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn');
+ if (grabBtnTeardown) {
+ grabBtnTeardown.classList.add('is-hidden');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place');
+ grabBtnTeardown.style.pointerEvents = '';
+ const im = grabBtnTeardown.querySelector('img');
+ if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
+ }
+ const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn');
+ if (gchJumpTeardown) {
+ gchJumpTeardown.classList.add('is-hidden');
+ gchJumpTeardown.setAttribute('aria-hidden', 'true');
+ gchJumpTeardown.style.pointerEvents = '';
+ }
+ const stackDropTeardown = document.getElementById('stack-tower-drop-btn');
+ if (stackDropTeardown) {
+ stackDropTeardown.classList.add('is-hidden');
+ stackDropTeardown.setAttribute('aria-hidden', 'true');
+ stackDropTeardown.style.pointerEvents = '';
+ stackDropTeardown.classList.remove('stack-tower-drop-btn--active');
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function setupPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry())
+ || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live');
+ if (hideBottomQuizBar) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ const leg = document.getElementById('quiz-play-legend');
+ if (previewMode) {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…';
+ if (leg) leg.textContent = '';
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ';
+ if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/';
+ if (leg) leg.textContent = '';
+ } else {
+ loadPreviewQuizAndStart();
+ }
+ } else {
+ playQuizPhaseLocal = null;
+ playQuizText = 'รอคำถามจากโฮสต์…';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)';
+ if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ }
+ if (isQuiz() && !isQuizCarry()) {
+ (async () => {
+ try {
+ let s = {};
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ try {
+ s = JSON.parse(trimmed);
+ } catch (e) { /* ignore */ }
+ }
+ }
+ s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {});
+ setQuizMapPanelThemeFromApi(s);
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e2) { /* ignore */ }
+ } catch (e) { /* ignore */ }
+ })();
+ }
+ }
+
+ /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */
+ function applyQuizPhaseFromServer(p) {
+ if (!p || !mapData || !isQuiz()) return;
+ if (p.text) playQuizText = p.text;
+ playQuizPhaseLocal = p.phase;
+ playQuizPhaseEndsAt = p.endsAt || 0;
+ playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0);
+ playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ const qEl = document.getElementById('quiz-game-question');
+ if (phaseEl) {
+ phaseEl.textContent = p.phase === 'read'
+ ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''))
+ : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''));
+ }
+ if (qEl) qEl.textContent = playQuizText || '';
+ const ov = document.getElementById('quiz-game-overlay');
+ if (isQuizQuestionMissionHudActivePlay()) {
+ if (ov) ov.classList.add('is-hidden');
+ } else if (ov) {
+ ov.classList.remove('is-hidden');
+ }
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
+ updatePlayQuizTimerDisplay();
+ if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
+ if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {};
+ try {
+ syncPlayQuizMapPanel();
+ } catch (eQm) { /* ignore */ }
+ }
+
+ function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
+ function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
+ function isGauntletFaceRightMapMno9kb07() {
+ if (!isGauntlet()) return false;
+ /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */
+ return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID;
+ }
+ /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */
+ function isGauntletCrownHeistMapPlay() {
+ return isGauntletFaceRightMapMno9kb07();
+ }
+ function isMegaVirusMissionShellMapPlay() {
+ return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID);
+ }
+ /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */
+ function usesCrownLobbyShellPlay() {
+ return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay();
+ }
+ /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */
+ const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49;
+ /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */
+ const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1;
+ function isStackTowerEmbedZoomLockedPlay() {
+ return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay());
+ }
+ function applyPlayEmbedZoomForCurrentMapPlay() {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL;
+ }
+ }
+
+ /** smoothstep 0..1 */
+ function stackTowerPost50SmoothstepPlay(t) {
+ const x = Math.max(0, Math.min(1, t));
+ return x * x * (3 - 2 * x);
+ }
+
+ /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */
+ function getStackTowerPost50AnimEasePlay() {
+ if (!getStackTowerCameraFollowPlayConfig().enabled) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now();
+ const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS));
+ return stackTowerPost50SmoothstepPlay(u);
+ }
+
+ function getStackTowerPost50ZMulPlay() {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 1;
+ return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e;
+ }
+
+ function getStackTowerPost50BgScrollExtraPxPlay(ch) {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 0;
+ const chR = Math.max(1, Number(ch) || 720);
+ return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e;
+ }
+
+ /**
+ * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul).
+ * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom.
+ */
+ function computePlayCameraZDrawPlay() {
+ if (!canvas || !mapData) return zoom;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ let zDraw = zoom;
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */
+ zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90;
+ }
+ } else if (isQuizCarry()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizBattle()) {
+ zDraw *= quizBattleUserZoomMul;
+ }
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) {
+ zDraw *= playEmbedUserZoomMul;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL;
+ zDraw *= getStackTowerPost50ZMulPlay();
+ }
+ return zDraw;
+ }
+ let gauntletCrownHowtoVisible = false;
+ function isGauntletCrownPregameBlockingPlay() {
+ if (!usesCrownLobbyShellPlay()) return false;
+ if (gauntletCrownRunHeldRemote) return true;
+ if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
+ return false;
+ }
+
+ /**
+ * Last Light (crown heist): center camera on the whole party bbox instead of only `me`
+ * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible.
+ */
+ function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) {
+ if (!isGauntletCrownHeistMapPlay() || !mapData) return null;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ww = md.width || 20;
+ const hh = md.height || 15;
+ const mapWpx = ww * tileSizeRef;
+ const mapHpx = hh * tileSizeRef;
+ const halfW = canvasW / (2 * zDrawVal);
+ const halfH = canvasH / (2 * zDrawVal);
+ const ents = [];
+ if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) {
+ ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef });
+ }
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return;
+ ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef });
+ });
+ if (!ents.length) return null;
+ let minCx = Infinity;
+ let maxCx = -Infinity;
+ let sumCy = 0;
+ for (let i = 0; i < ents.length; i++) {
+ const e = ents[i];
+ minCx = Math.min(minCx, e.cx);
+ maxCx = Math.max(maxCx, e.cx);
+ sumCy += e.cy;
+ }
+ let px = (minCx + maxCx) * 0.5;
+ let py = sumCy / ents.length;
+ const minCamX = halfW;
+ const maxCamX = Math.max(minCamX, mapWpx - halfW);
+ const minCamY = halfH;
+ const maxCamY = Math.max(minCamY, mapHpx - halfH);
+ const viewW = halfW * 2;
+ const viewH = halfH * 2;
+ /*
+ * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก)
+ * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw)
+ */
+ if (viewW >= mapWpx) px = mapWpx * 0.5;
+ else px = Math.max(minCamX, Math.min(maxCamX, px));
+ if (viewH >= mapHpx) py = mapHpx * 0.5;
+ else py = Math.max(minCamY, Math.min(maxCamY, py));
+ return { px, py };
+ }
+
+ /** Preview bots only — full tail-off loss needs server support for real humans */
+ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40;
+ function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) {
+ if (!playBotsEnabled() || !isGauntletCrownHeistMapPlay() || !mapData) return;
+ if (gauntletCrownPregamePhase !== 'live') return;
+ const halfW = canvas.width / (2 * zDrawVal);
+ const worldMinX = camX - halfW;
+ const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const ts = mapData.tileSize || 32;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return;
+ const rightWorld = (Number(o.x) + cw) * ts;
+ if (rightWorld < gate) o.gauntletEliminated = true;
+ });
+ }
+
+ function isStack() { return mapData && mapData.gameType === 'stack'; }
+ function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
+
+ function currentPlayMapId() {
+ const q = (playSessionMapId || '').trim();
+ if (q) return q;
+ if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim();
+ return (playMapIdFromQuery || '').trim();
+ }
+
+ function isQuizQuestionMissionUiMapPlay() {
+ return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID;
+ }
+
+ function isQuizQuestionMissionPregameBlockingPlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionUiMapPlay() {
+ return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID;
+ }
+
+ function getStackTowerBlockWorldScalePlay() {
+ return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ }
+
+ function isStackTowerMissionPregameBlockingPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return false;
+ return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionHudActivePlay() {
+ return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ }
+
+ /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */
+ function isJumpSurviveMissionHudActivePlay() {
+ return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live';
+ }
+
+ /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */
+ function isSpaceShooterMissionHudActivePlay() {
+ return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live';
+ }
+
+ function stackTowerAssetUrl(file) {
+ return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, '');
+ }
+
+ function ensureStackTowerSlingImagePlay() {
+ if (!stackTowerSlingImg) {
+ stackTowerSlingImg = new Image();
+ stackTowerSlingImg.decoding = 'async';
+ stackTowerSlingImg.src = stackTowerAssetUrl('sling.png');
+ }
+ const im = stackTowerSlingImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerScorePopupImagesPlay() {
+ if (!stackTowerScorePlus10Img) {
+ stackTowerScorePlus10Img = new Image();
+ stackTowerScorePlus10Img.decoding = 'async';
+ stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png');
+ }
+ if (!stackTowerScorePlus20Img) {
+ stackTowerScorePlus20Img = new Image();
+ stackTowerScorePlus20Img.decoding = 'async';
+ stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png');
+ }
+ return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img };
+ }
+
+ function ensureStackTowerPerfectFxImagesPlay() {
+ if (!stackTowerScoreLightImg) {
+ stackTowerScoreLightImg = new Image();
+ stackTowerScoreLightImg.decoding = 'async';
+ stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png');
+ }
+ if (!stackTowerScoreX2Img) {
+ stackTowerScoreX2Img = new Image();
+ stackTowerScoreX2Img.decoding = 'async';
+ stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png');
+ }
+ return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img };
+ }
+
+ /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */
+ function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) {
+ if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return;
+ const iw = x2Img.naturalWidth;
+ const ih = x2Img.naturalHeight;
+ const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72));
+ const scale = targetH / ih;
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const cx = sx + drawW + drawW * 0.06;
+ const cy = sy + drawH * 0.5;
+ const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1;
+ const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI);
+ if (lightImg && lightImg.complete && lightImg.naturalWidth) {
+ const lp = 2.7;
+ const lw = dw * lp;
+ const lh = dh * lp;
+ const lnx = lightImg.naturalWidth;
+ const lny = lightImg.naturalHeight;
+ /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */
+ const iconCx = cx + dw * 0.02;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, pulse + 0.34);
+ ctx.globalCompositeOperation = 'screen';
+ try {
+ ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.globalCompositeOperation = 'source-over';
+ try {
+ ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+
+ function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) {
+ if (!m.scorePopups || !m.scorePopups.length) return;
+ const now = performance.now();
+ const dur = STACK_TOWER_SCORE_POPUP_MS;
+ const imgs = ensureStackTowerScorePopupImagesPlay();
+ m.scorePopups = m.scorePopups.filter((p) => p.until > now);
+ const hidPop = getStackTowerVisualHiddenLayerCountPlay();
+ for (let pi = 0; pi < m.scorePopups.length; pi++) {
+ const p = m.scorePopups[pi];
+ const li = Math.floor(Number(p.layerIndex)) || 0;
+ if (li < 0 || li >= m.layers.length) continue;
+ if (li < hidPop) continue;
+ const L = m.layers[li];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay();
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (li + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const t0 = p.until - dur;
+ const t = Math.max(0, Math.min(1, (now - t0) / dur));
+ const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t);
+ const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom;
+ const im = p.kind === '20' ? imgs.p20 : imgs.p10;
+ const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom));
+ let iw = targetW;
+ let ih = targetW * 0.38;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ ih = (targetW * im.naturalHeight) / im.naturalWidth;
+ }
+ const cx = sx + drawW / 2;
+ const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha));
+ if (im && im.complete && im.naturalWidth > 0) {
+ try {
+ ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e) { /* ignore */ }
+ } else {
+ ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd';
+ ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy);
+ }
+ ctx.restore();
+ }
+ }
+
+ /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */
+ function spawnStackMissDebrisPlay(sf) {
+ if (!sf || !stackMini) return;
+ const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3);
+ const cxW = sf.xLeft1 + sf.tw / 2;
+ const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0;
+ const dir = landOff >= 0 ? 1 : -1;
+ stackMissDebris.push({
+ cxW,
+ topY: sf.y1,
+ w: sf.tw,
+ h: lh,
+ vx: dir * (tileSize * (1.4 + Math.random() * 0.9)),
+ vy: -tileSize * (0.4 + Math.random() * 0.5),
+ rot: sf.tiltMax || 0,
+ vrot: dir * (2.0 + Math.random() * 1.8),
+ seat: sf.actorSeat,
+ heavy: !!sf.actorHeavy,
+ hue: (stackMini.layers.length * 47) % 360,
+ });
+ while (stackMissDebris.length > 6) stackMissDebris.shift();
+ _stackDebrisLastT = 0;
+ }
+ /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */
+ function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) {
+ if (!stackMissDebris.length) return;
+ const now = performance.now();
+ let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016;
+ _stackDebrisLastT = now;
+ if (dt > 0.05) dt = 0.05;
+ const g = tileSize * 34;
+ const chCv = canvas && canvas.height ? canvas.height : 720;
+ const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5;
+ for (let i = stackMissDebris.length - 1; i >= 0; i--) {
+ const d = stackMissDebris[i];
+ d.vy += g * dt;
+ d.topY += d.vy * dt;
+ d.cxW += d.vx * dt;
+ d.rot += d.vrot * dt;
+ if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; }
+ const sp = worldToScreen(d.cxW - d.w / 2, d.topY);
+ const drawW = d.w * zoom;
+ const drawH = d.h * zoom;
+ ctx.save();
+ ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2);
+ ctx.rotate(d.rot);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true });
+ ctx.restore();
+ }
+ }
+
+ function ensureStackTowerHeartMinusImgPlay() {
+ if (!stackTowerHeartMinusImg) {
+ stackTowerHeartMinusImg = new Image();
+ stackTowerHeartMinusImg.decoding = 'async';
+ stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png');
+ }
+ const im = stackTowerHeartMinusImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerLifeHudImagesPlay() {
+ if (!stackTowerLifeBarImg) {
+ stackTowerLifeBarImg = new Image();
+ stackTowerLifeBarImg.decoding = 'async';
+ stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png');
+ }
+ if (!stackTowerLifeHitImg) {
+ stackTowerLifeHitImg = new Image();
+ stackTowerLifeHitImg.decoding = 'async';
+ stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png');
+ }
+ if (!stackTowerLifeFullImg) {
+ stackTowerLifeFullImg = new Image();
+ stackTowerLifeFullImg.decoding = 'async';
+ stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png');
+ }
+ }
+
+ /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */
+ function ensureViolentCrimeLifeHudImagesPlay() {
+ if (!violentCrimeLifeBarImg) {
+ violentCrimeLifeBarImg = new Image();
+ violentCrimeLifeBarImg.decoding = 'async';
+ violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png');
+ }
+ if (!violentCrimeLifeHitImg) {
+ violentCrimeLifeHitImg = new Image();
+ violentCrimeLifeHitImg.decoding = 'async';
+ violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png');
+ }
+ if (!violentCrimeLifeFullImg) {
+ violentCrimeLifeFullImg = new Image();
+ violentCrimeLifeFullImg.decoding = 'async';
+ violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png');
+ }
+ return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg };
+ }
+
+ /**
+ * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG)
+ */
+ const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38;
+
+ /**
+ * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง
+ */
+ function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) {
+ const imgs = (opts && opts.images)
+ ? opts.images
+ : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg });
+ const bar = imgs.bar;
+ const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0)
+ ? imgs.full
+ : ensureStackTowerHeartMinusImgPlay();
+ const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0
+ ? imgs.hit
+ : null;
+ const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1));
+ const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0));
+
+ if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) {
+ ctx.save();
+ ctx.fillStyle = '#bb9af7';
+ ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ let heartStr = '';
+ for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ ';
+ ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5);
+ ctx.restore();
+ return Math.min(maxH, 28);
+ }
+
+ const bw0 = bar.naturalWidth;
+ const bh0 = bar.naturalHeight;
+ const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC));
+ const scale = Math.min(maxW / bw0, 1.45);
+ const bw = bw0 * scale;
+ const titleDh = titleSrcH * scale;
+ const gap = 3;
+ const bx = destX;
+ const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35);
+ const heartsTop = by0 + titleDh + gap;
+ const roomHearts = Math.max(10, destY + maxH - heartsTop - 2);
+ const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5)));
+
+ ctx.save();
+ try {
+ ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh);
+ } catch (e) { /* ignore */ }
+
+ const cy = heartsTop + heartsH * 0.5;
+ const bracketW = Math.min(14, Math.max(9, bw * 0.07));
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.88)';
+ ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'left';
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.45)';
+ ctx.shadowBlur = 4;
+ ctx.fillText('[', bx + 1, cy);
+ ctx.textAlign = 'right';
+ ctx.fillText(']', bx + bw - 1, cy);
+ ctx.shadowBlur = 0;
+
+ const ix = bx + bracketW + 4;
+ const iwInner = Math.max(24, bw - (bracketW + 4) * 2);
+ const slotW = iwInner / slots;
+ const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34);
+
+ for (let i = 0; i < slots; i++) {
+ const cx = ix + slotW * (i + 0.5);
+ const useFull = i < lives;
+ const im = useFull ? fullIm : hitIm;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ const nw = im.naturalWidth;
+ const nh = im.naturalHeight;
+ let ih = iconMax;
+ let iw = (ih * nw) / nh;
+ const capW = slotW * 0.82;
+ const capH = heartsH * 0.88;
+ if (iw > capW) {
+ iw = capW;
+ ih = (iw * nh) / nw;
+ }
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ if (iw < 6 && nh > 0) {
+ iw = 6;
+ ih = (iw * nh) / nw;
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ }
+ try {
+ ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e2) { /* ignore */ }
+ } else {
+ ctx.fillStyle = useFull ? '#9ece6a' : '#565f89';
+ ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(useFull ? '♥' : '♡', cx, cy);
+ }
+ }
+ ctx.restore();
+ const usedH = heartsTop + heartsH - by0 + 2;
+ return Math.min(maxH, Math.max(titleDh, usedH));
+ }
+
+ function triggerStackTowerHeartMinusFxPlay() {
+ if (!isStackTowerMissionHudActivePlay()) return;
+ stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS };
+ }
+
+ function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) {
+ if (!stackTowerHeartMinusFx) return;
+ const nt = performance.now();
+ if (nt >= stackTowerHeartMinusFx.until) {
+ stackTowerHeartMinusFx = null;
+ return;
+ }
+ const dur = STACK_TOWER_HEART_MINUS_FX_MS;
+ const t0 = stackTowerHeartMinusFx.until - dur;
+ const u = Math.max(0, Math.min(1, (nt - t0) / dur));
+ const alpha = 1 - u * u;
+ const worldCx = m.topCenterX * tileSize;
+ const stackTopY = floorY - nLay * layerWorldH;
+ const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5;
+ const [sx, sy] = worldToScreen(worldCx, worldCy);
+ const im = ensureStackTowerHeartMinusImgPlay();
+ const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom));
+ let dw = maxW;
+ let dh = maxW * 0.52;
+ if (im && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ dh = (maxW * im.naturalHeight) / im.naturalWidth;
+ }
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha));
+ if (im) {
+ ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('♥ −1', sx, sy);
+ }
+ ctx.restore();
+ }
+
+ /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */
+ function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) {
+ if (drawW <= 0 || drawH <= 0) return;
+ const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS));
+ const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI);
+ ctx.save();
+ ctx.globalCompositeOperation = 'source-over';
+ /* เติมเหลืองทับบล็อกให้เด่น */
+ ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32);
+ ctx.fillStyle = 'rgba(255, 224, 60, 0.55)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */
+ const pad = Math.max(3, Math.min(12, drawW * 0.05));
+ const sw = Math.max(3, Math.min(9, drawW * 0.055));
+ ctx.shadowColor = 'rgba(255, 196, 24, 0.98)';
+ ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16));
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 0;
+ ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38);
+ ctx.strokeStyle = 'rgba(255, 238, 110, 1)';
+ ctx.lineWidth = sw;
+ ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw);
+ /* เส้นในขาวอมเหลือง เพิ่มความคม */
+ ctx.shadowBlur = 0;
+ ctx.shadowColor = 'transparent';
+ ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4);
+ ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)';
+ ctx.lineWidth = Math.max(1.5, sw * 0.42);
+ ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2);
+ ctx.restore();
+ }
+
+ /**
+ * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว
+ */
+ function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) {
+ const dx = sx1 - sx0;
+ const dy = sy1 - sy0;
+ const len = Math.hypot(dx, dy);
+ if (len < 1.5) return;
+ const sling = ensureStackTowerSlingImagePlay();
+ if (sling) {
+ const nw = sling.naturalWidth;
+ const nh = sling.naturalHeight;
+ const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2));
+ const ang = Math.atan2(dy, dx);
+ ctx.save();
+ ctx.translate(sx0, sy0);
+ ctx.rotate(ang - Math.PI / 2);
+ ctx.globalAlpha = 0.96;
+ try {
+ ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len);
+ } catch (e) { /* ignore */ }
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ } else {
+ ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)';
+ ctx.lineWidth = Math.max(2.2, zoom * 1.4);
+ ctx.beginPath();
+ ctx.moveTo(sx0, sy0);
+ ctx.lineTo(sx1, sy1);
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ }
+ }
+
+ const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower';
+
+ function setGauntletCrownHowtoStackTowerArtMaskPlay(on) {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!hov) return;
+ hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on);
+ }
+
+ function hideStackTowerHowtoRulesDom() {
+ /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */
+ }
+
+ function teardownStackTowerMissionUiPlay() {
+ stackTowerMissionPhase = null;
+ stackTowerMissionDeferredPhase = null;
+ stackTowerSessionStartAt = 0;
+ stackTowerMissionEndedOnce = false;
+ stackTowerPost50AnimStartMs = null;
+ hideStackTowerHowtoRulesDom();
+ setGauntletCrownHowtoStackTowerArtMaskPlay(false);
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function hideStackTowerResultFlashDomOnlyPlay() {
+ if (stackTowerResultFlashTimer) {
+ clearTimeout(stackTowerResultFlashTimer);
+ stackTowerResultFlashTimer = null;
+ }
+ const ov = document.getElementById('stack-tower-result-flash');
+ const img = document.getElementById('stack-tower-result-flash-img');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ if (img) {
+ img.onerror = null;
+ img.removeAttribute('src');
+ }
+ }
+
+ /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */
+ function beginStackTowerResultFlashThenGcm(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outc = String(mission.stackTowerOutcome || 'time_up');
+ let file = 'result-timeup.png';
+ if (outc === 'decrypt_complete') file = 'result-complete.png';
+ else if (outc === 'misses_exhausted') file = 'result-gameover.png';
+ const url = stackTowerAssetUrl(file);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = url;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ /** Jumper mnptfts2: เกรด F = ภาพ gameover · อื่น = complete (ก่อนแผงสรุปผล) */
+ function jumpSurviveMissionOutcomeImageFile(mission) {
+ const g = mission && String(mission.grade || '').toUpperCase();
+ if (g === 'F') return 'result-gameover.png';
+ return 'result-complete.png';
+ }
+
+ /**
+ * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome;
+ * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM
+ */
+ function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'jumper') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = jumperAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash(outcomeFile, finishToGcm);
+ });
+ } else {
+ showOneFlash(outcomeFile, finishToGcm);
+ }
+ }
+
+ /**
+ * Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM;
+ * ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM
+ */
+ function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'violent_crime') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = violentCrimeAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash('result-gameover.png', finishToGcm);
+ });
+ } else {
+ showOneFlash('result-gameover.png', finishToGcm);
+ }
+ }
+
+ /**
+ * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM
+ * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow)
+ * — ชนะ = end-victory-2.png ใน MegaVirus
+ */
+ function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) {
+ if (!mission || mission.uiSkin !== 'mega_virus') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const lose = endReason === 'all_dead' || endReason === 'time';
+ const flashSrc = lose ? stackTowerAssetUrl('result-gameover.png') : megaVirusAssetUrl('end-victory-2.png');
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = flashSrc;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ function stackTowerMissionTimeLimitSecPlay() {
+ const t = Number(playStackTowerMissionTimeSec);
+ if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t)));
+ return 90;
+ }
+
+ function stackTowerProgressBlocksPlay() {
+ const n = Number(playStackTowerProgressBlocks);
+ if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n)));
+ return 50;
+ }
+
+ function stackTowerDecryptPctPlay() {
+ if (!stackMini) return 0;
+ if (isStackTowerMissionUiMapPlay()) {
+ const p = Number(stackMini.progressPct);
+ const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0));
+ if (x >= 99.999) return 100;
+ return Math.floor(x);
+ }
+ const teamScore = stackMini.score || 0;
+ return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
+ }
+
+ function stackTowerMissionElapsedSecPlay() {
+ if (stackTowerSessionStartAt <= 0) return 0;
+ return (performance.now() - stackTowerSessionStartAt) / 1000;
+ }
+
+ function applyStackTowerMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = stackTowerAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = stackTowerAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ ensureStackTowerSlingImagePlay();
+ }
+
+ function showStackTowerMissionHowtoOverlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ stackTowerMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ setGauntletCrownHowtoStackTowerArtMaskPlay(true);
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ }
+ }
+
+ /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */
+ function updateStackTowerMissionHowtoHud() {
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */
+ function updateJumpSurviveMissionHowtoHud() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */
+ function updateSpaceShooterMissionHowtoHud() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginStackTowerMissionCountdownThenRun() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+ gauntletCrownHowtoVisible = false;
+ hideStackTowerHowtoRulesDom();
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'live';
+ stackTowerMissionEndedOnce = false;
+ stackTowerSessionStartAt = performance.now();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ stackTowerMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function stackTowerMissionBuildPayload(endOpts) {
+ const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete';
+ const rows = [];
+ const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ function pushEnt(id, nickname, characterId, score) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0);
+ }
+ stackPreviewTurnOrder.forEach(function (entry) {
+ if (!entry || entry.kind !== 'bot' || !entry.botId) return;
+ const o = others.get(entry.botId);
+ pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0);
+ });
+ } else {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ pushEnt(id, o.nickname, o.characterId, teamPts);
+ });
+ }
+ const seen = new Set();
+ const uniq = [];
+ rows.forEach(function (r) {
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ uniq.push(r);
+ });
+ uniq.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = uniq.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && uniq.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: uniq.length,
+ participantCount: uniq.length,
+ stackTowerOutcome: outcome,
+ };
+ }
+
+ function stackTowerMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.stackBotScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.stackBotScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ stackTowerOutcome: mission.stackTowerOutcome,
+ };
+ }
+
+ function stackTowerMissionMaybeEndPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionPhase !== 'live') return;
+ if (stackTowerMissionEndedOnce) return;
+ const pct = stackTowerDecryptPctPlay();
+ const elapsed = stackTowerMissionElapsedSecPlay();
+ const timeLim = stackTowerMissionTimeLimitSecPlay();
+ const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0;
+ const timeOut = elapsed >= timeLim;
+ if (pct < 100 && !timeOut && !missesOut) return;
+ stackTowerMissionEndedOnce = true;
+ stackTowerMissionPhase = 'ended';
+ applyStackTowerMissionPanelImages();
+ let outcome = 'time_up';
+ if (pct >= 100) outcome = 'decrypt_complete';
+ else if (missesOut) outcome = 'misses_exhausted';
+ beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome }));
+ }
+
+ function isJumpSurviveMissionUiMapPlay() {
+ return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID;
+ }
+
+ function isJumpSurviveMissionPregameBlockingPlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return false;
+ return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended';
+ }
+
+ function jumperAssetUrl(file) {
+ return BASE + '/img/Jumper/' + file;
+ }
+
+ function hideConflictingOverlaysForJumpSurviveMission() {
+ hideConflictingOverlaysForGauntletCrown();
+ }
+
+ function jumpSurviveTimeLimitSecForMission() {
+ const m = Number(mapData && mapData.jumpSurviveTimeSec);
+ if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
+ const j = Number(playJumpSurviveMissionTimeSec);
+ if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j);
+ return 60;
+ }
+
+ function jumpSurviveRemainingSecMission() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null;
+ const lim = jumpSurviveTimeLimitSecForMission();
+ if (!(lim > 0)) return null;
+ const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function applyJumpSurviveJumperPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = jumperAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = jumperAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showJumpSurviveMissionHowtoOverlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ jumpSurviveMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ }
+ }
+
+ function beginJumpSurviveMissionCountdownThenRun() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'live';
+ jumpSurviveGameEnded = false;
+ jumpSurviveEliminated = false;
+ applyJumpSurvivePreviewSpawnLayout(false);
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ jumperMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */
+ function jumpSurviveGradeFromSurvivorCount(survivors) {
+ const s = Math.max(0, Math.floor(Number(survivors) || 0));
+ if (s >= 6) return 'A';
+ if (s === 5) return 'B';
+ if (s === 4) return 'C';
+ if (s === 3) return 'D';
+ if (s === 2) return 'E';
+ return 'F';
+ }
+
+ function jumpSurviveRollRewardCardForGrade(grade) {
+ if (grade === 'F') return null;
+ const r = Math.random();
+ if (grade === 'A' || grade === 'B') {
+ if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C' || grade === 'D') {
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'E') {
+ if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ function jumpSurviveMissionTotalParticipants() {
+ let n = myId != null ? 1 : 0;
+ if (others && typeof others.size === 'number') n += others.size;
+ return n;
+ }
+
+ function jumpSurviveMissionCountAlive() {
+ let n = 0;
+ if (myId != null && !jumpSurviveEliminated) n++;
+ if (others && typeof others.forEach === 'function') {
+ others.forEach(function (o) {
+ if (o && !o.jumpSurviveEliminated) n++;
+ });
+ }
+ return n;
+ }
+
+ /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */
+ function jumpSurviveMissionMaybeEarlyFinish() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const alive = jumpSurviveMissionCountAlive();
+ if (alive === 0) {
+ endJumpSurviveMissionRound('all_dead');
+ }
+ }
+
+ function jumpSurviveBuildMissionPayload() {
+ const rows = [];
+ const pushEnt = function (id, nickname, characterId, eliminated) {
+ const alive = !eliminated;
+ const baseScore = alive ? 100 : 0;
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: baseScore,
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: baseScore,
+ });
+ };
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < rows.length; si++) {
+ if (rows[si] && !rows[si].eliminated) survivorCount++;
+ }
+ const participantCount = rows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = missionEnsureHumanInTopRanked(rows, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const rewardCard = jumpSurviveRollRewardCardForGrade(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function jumpSurviveMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.jumpSurviveEliminated ? 0 : 100,
+ eliminated: !!o.jumpSurviveEliminated,
+ rankBonus: 0,
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < baseRows.length; si++) {
+ if (baseRows[si] && !baseRows[si].eliminated) survivorCount++;
+ }
+ const participantCount = baseRows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: jumpSurviveRollRewardCardForGrade(grade),
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function endJumpSurviveMissionRound(endKind) {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return;
+ jumpSurviveGameEnded = true;
+ jumpSurviveMissionPhase = 'ended';
+ applyJumpSurviveJumperPanelImages();
+ const mission = jumpSurviveBuildMissionPayload();
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+ function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
+
+ function isSpaceShooterMissionUiMapPlay() {
+ return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID;
+ }
+
+ function isSpaceShooterMissionPregameBlockingPlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return false;
+ return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended';
+ }
+
+ function violentCrimeAssetUrl(file) {
+ return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, '');
+ }
+
+ function megaVirusAssetUrl(file) {
+ return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, '');
+ }
+
+ function applyGauntletCrownHeistMissionPanelImages() {
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isGauntletCrownHeistMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ howtoOv.classList.toggle('gch-mg2-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = missionMockHudAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg && isGauntletCrownHeistMapPlay()) {
+ resBg.src = missionMockHudAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applyMegaVirusMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = megaVirusAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = megaVirusAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applySpaceShooterMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = violentCrimeAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = violentCrimeAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showSpaceShooterMissionHowtoOverlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ spaceShooterMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ spaceShooterResetMissionShipStateForAllPlay();
+ }
+
+ function beginSpaceShooterMissionCountdownThenRun() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'live';
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ if (myId != null) me.spaceShooterScore = 0;
+ others.forEach(function (o) {
+ if (o) o.spaceShooterScore = 0;
+ });
+ spaceShooterResetMissionShipStateForAllPlay();
+ if (mapData) applySpaceShooterSpawnLayoutPlay();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ spaceShooterMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function spaceShooterBuildMissionPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score, eliminated) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: rows.length,
+ };
+ }
+
+ function spaceShooterMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ eliminated: !!o.spaceShooterEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function balloonBossMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ eliminated: !!o.balloonBossEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach(function (o) {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ const endR = mission && mission.balloonBossEndReason;
+ if (endR === 'victory' || progress >= 1) grade = 'A';
+ else if (endR === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length,
+ participantCount: baseRows.length,
+ balloonBossEndReason: mission.balloonBossEndReason,
+ };
+ }
+
+ function balloonBossBuildMissionPayloadPlay(reason) {
+ const rows = [];
+ if (myId != null) {
+ rows.push({
+ id: myId,
+ nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'),
+ characterId: me.characterId ?? null,
+ baseScore: Math.max(0, me.balloonBossScore | 0),
+ eliminated: !!me.balloonBossEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ rows.push({
+ id: id,
+ nickname: (o && o.nickname) ? String(o.nickname).trim() : id,
+ characterId: o && o.characterId != null ? o.characterId : null,
+ baseScore: Math.max(0, o.balloonBossScore | 0),
+ eliminated: !!(o && o.balloonBossEliminated),
+ });
+ });
+ rows.sort((a, b) => b.baseScore - a.baseScore
+ || String(a.nickname).localeCompare(String(b.nickname), 'th')
+ || String(a.id).localeCompare(String(b.id)));
+ const top = rows.slice(0, 5);
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map((r) => r.baseScore);
+ const totalSum = totalParts.reduce((s, n) => s + n, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ if (reason === 'victory' || progress >= 1) grade = 'A';
+ else if (reason === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: rows.filter((r) => !r.eliminated).length,
+ participantCount: rows.length,
+ balloonBossEndReason: reason,
+ };
+ }
+
+ function endSpaceShooterMissionRound(endKind) {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return;
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ spaceShooterGameEnded = true;
+ spaceShooterMissionPhase = 'ended';
+ applySpaceShooterMissionPanelImages();
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterPopups = [];
+ const mission = spaceShooterBuildMissionPayload();
+ mission.spaceShooterEndKind = kind;
+ beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+
+ function questionMissionAssetUrl(file) {
+ return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, '');
+ }
+
+ /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */
+ function questionMissionHudAssetUrl(file) {
+ if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file);
+ return questionMissionAssetUrl(file);
+ }
+
+ /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */
+ function getMissionMockHudSkinPlay() {
+ if (isQuizQuestionMissionUiMapPlay()) return 'question_mission';
+ if (isStackTowerMissionUiMapPlay()) return 'stack_tower';
+ if (isJumpSurviveMissionUiMapPlay()) return 'jumper';
+ if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime';
+ if (isMegaVirusMissionShellMapPlay()) return 'mega_virus';
+ if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry';
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return '';
+ }
+
+ function isMissionMockHudPlay() {
+ return !!getMissionMockHudSkinPlay();
+ }
+
+ function missionMockHudAssetUrl(file) {
+ const skin = getMissionMockHudSkinPlay();
+ const f = String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(f);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(f);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(f);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(f);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f;
+ return questionMissionAssetUrl(f);
+ }
+
+ function applyMissionMockHudCssVarsPlay(root) {
+ if (!root) return;
+ if (!isMissionMockHudPlay()) {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.removeProperty('--mmh-score-label');
+ root.style.removeProperty('--mmh-score-avatar');
+ root.style.removeProperty('--mmh-mic-on');
+ root.style.removeProperty('--mmh-btn-ready');
+ root.style.removeProperty('--mmh-btn-start');
+ delete root.dataset.missionMockSkin;
+ return;
+ }
+ const skin = getMissionMockHudSkinPlay();
+ root.dataset.missionMockSkin = skin;
+ if (skin === 'gauntlet_crown') {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')");
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (crownHead && crownImg) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ crownImg.classList.add('mg2-score-label');
+ }
+ } else {
+ root.style.removeProperty('--mmh-score-label');
+ root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "')");
+ }
+ root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')");
+ root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')");
+ root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')");
+ root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')");
+ const micImg = document.getElementById('play-cyber-mic-img');
+ if (micImg) {
+ micImg.classList.remove('is-hidden');
+ micImg.setAttribute('aria-hidden', 'false');
+ const micSrc = missionMockHudAssetUrl('btn-mic_on.png');
+ if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc);
+ }
+ const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay');
+ if (frameOverlay) {
+ const frameSrc = missionMockHudAssetUrl('Avartar.png');
+ if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc);
+ }
+ }
+
+ function isQuizQuestionMissionHudActivePlay() {
+ return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ }
+
+ /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
+ function getQuizQuestionMissionMapCenterWorldPxPlay() {
+ if (!mapData || !isQuiz()) return null;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ const ts = tileSize;
+ const mwPx = w * ts;
+ const mhPx = h * ts;
+ return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx };
+ }
+
+ function applyQuizQuestionMissionPanelImages() {
+ /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isQuizQuestionMissionUiMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = questionMissionAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = questionMissionAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showQuizQuestionMissionHowtoOverlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ quizQuestionMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ /** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ }
+ }
+
+ function beginQuizQuestionMissionCountdownThenRun() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'live';
+ playEmbedUserZoomMul = 1;
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) {
+ if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden');
+ else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden');
+ else qov.classList.remove('is-hidden');
+ }
+ const dp = quizQuestionMissionDeferredPhase;
+ quizQuestionMissionDeferredPhase = null;
+ if (dp) applyQuizPhaseFromServer(dp);
+ if (previewMode && isQuiz()) loadPreviewQuizAndStart();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ quizQuestionMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function quizQuestionMissionBuildPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score) {
+ const sid = String(id);
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && rows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: rows.length,
+ participantCount: rows.length,
+ };
+ }
+
+ function quizQuestionMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: !!r.hadQuizWrong,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
+ function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
+
+ /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */
+ function quizCarryEmbedMissionFlowActive() {
+ if (!isQuizCarry()) return false;
+ if (previewMode && editorEmbedReturn) return true;
+ return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay);
+ }
+ /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */
+ function getQuizCarryMapCameraWorldCenterPxPlay() {
+ if (!mapData || mapData.gameType !== 'quiz_carry') return null;
+ const ww = mapData.width || 20, hh = mapData.height || 15;
+ const ts = tileSize;
+ return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 };
+ }
+ function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
+
+ function quizCarryNeonColorForChoice(choiceIndex) {
+ const i = Math.max(0, choiceIndex | 0);
+ const h = (i * 47 + 100) % 360;
+ return 'hsl(' + h + ', 72%, 62%)';
+ }
+
+ function quizCarryMinimapOptionFillCss(ov) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1);
+ if (th.borderMode === 'fixed' && th.fixedBorder) {
+ const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim());
+ if (m) {
+ const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1;
+ const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32;
+ const t = Math.round(aa * 1000) / 1000;
+ return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')';
+ }
+ }
+ const i = Math.max(0, ov - 1);
+ const h = (i * 47 + 100) % 360;
+ return 'hsla(' + h + ', 58%, 52%, 0.32)';
+ }
+
+ function normalizeQuizCarryQuestionFromAny(q) {
+ if (!q) return null;
+ let text = String(q.text || q.question || q.prompt || q.title || '').trim();
+ if (Array.isArray(q.choices) && q.choices.length >= 2) {
+ const choices = q.choices.map((c) => String(c));
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
+ if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
+ let chSlot = Number(q.countdownHighlightSlot);
+ if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null;
+ else chSlot = Math.floor(chSlot);
+ let choiceImageUrls = null;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) choiceImageUrls = urls;
+ }
+ return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls };
+ }
+ if (!text) return null;
+ const t = !!q.answerTrue;
+ return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null };
+ }
+
+ function buildQuizCarryPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ const out = [];
+ for (let i = 0; i < md.quizQuestions.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
+ if (n) out.push(n);
+ }
+ return out;
+ }
+
+ function getQuizCarryHubTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ const grid = md.quizCarryHubArea;
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (row[xx] === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
+ function getQuizCarryOptionClusterBounds(md, slot1toN) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let n = 0, sumX = 0, sumY = 0;
+ for (let yy = 0; yy < g.length; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === slot1toN) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ sumX += xx + 0.5;
+ sumY += yy + 0.5;
+ n++;
+ }
+ }
+ }
+ if (!n || minX === Infinity) return null;
+ return {
+ minX, minY, maxX, maxY,
+ cx: sumX / n,
+ cy: sumY / n,
+ };
+ }
+
+ function canvasWordWrapLines(ctx, text, maxWidth) {
+ const raw = String(text || '').trim();
+ if (!raw) return [];
+ const words = raw.split(/\s+/);
+ const lines = [];
+ let cur = words[0];
+ for (let i = 1; i < words.length; i++) {
+ const w = words[i];
+ const test = cur + ' ' + w;
+ if (ctx.measureText(test).width <= maxWidth) cur = test;
+ else {
+ lines.push(cur);
+ cur = w;
+ }
+ }
+ lines.push(cur);
+ return lines;
+ }
+
+ function quizCarryOptionHeldByAnyone(optionIdx) {
+ if (optionIdx == null || optionIdx < 0) return false;
+ if (me.quizCarryHeld === optionIdx) return true;
+ for (const o of others.values()) {
+ if (o && o.quizCarryHeld === optionIdx) return true;
+ }
+ return false;
+ }
+
+ /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */
+ function spriteOverlapsQuizCarryHubArea(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !sp) return false;
+ const hub = md.quizCarryHubArea;
+ if (!hub || !hub.length) return false;
+ const mw = md.width || 20, mh = md.height || 15;
+ const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = hub[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ if (row[xx] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
+ function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1) return null;
+ let p = preferredIdx | 0;
+ if (p < 0 || p >= n) p = 0;
+ if (!quizCarryOptionHeldByAnyone(p)) return p;
+ const avail = [];
+ for (let i = 0; i < n; i++) {
+ if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
+ }
+ if (!avail.length) return null;
+ return avail[Math.floor(Math.random() * avail.length)];
+ }
+
+ /**
+ * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl
+ * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
+ * @param plaqueExtra optional { imageUrl }
+ */
+ function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex);
+ const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex);
+ const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)';
+ const textCol = th.textColor || '#f8f9ff';
+ const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
+ const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
+ const elImg = plaqueExtra && plaqueExtra.imageElement;
+ const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
+ const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
+ const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
+ const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
+ const simplePreviewPlaque = previewMode && editorEmbedReturn;
+ function pathBoard() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
+ else ctx.rect(signX, signY, w, h);
+ }
+ if (!simplePreviewPlaque) {
+ for (let g = 4; g >= 1; g--) {
+ ctx.strokeStyle = neon;
+ ctx.globalAlpha = 0.07 + g * 0.06;
+ ctx.lineWidth = baseLw + g * 3.2;
+ pathBoard();
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = fillCol;
+ pathBoard();
+ ctx.fill();
+ if (drawPlaqueImg) {
+ ctx.save();
+ pathBoard();
+ ctx.clip();
+ const padImg = Math.max(2, padY * 0.65);
+ const ix = signX + padImg;
+ const iy = signY + padImg;
+ const iw = Math.max(0, w - padImg * 2);
+ const ih = Math.max(0, h - padImg * 2);
+ if (iw > 0 && ih > 0) {
+ const nw = drawPlaqueImg.naturalWidth;
+ const nh = drawPlaqueImg.naturalHeight;
+ const ir = nw / nh;
+ let dw;
+ let dh;
+ if (iw / ih > ir) {
+ dh = ih;
+ dw = dh * ir;
+ } else {
+ dw = iw;
+ dh = dw / ir;
+ }
+ const dx = ix + (iw - dw) / 2;
+ const dy = iy + (ih - dh) / 2;
+ ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
+ }
+ ctx.restore();
+ }
+ ctx.strokeStyle = neon;
+ if (simplePreviewPlaque) {
+ ctx.lineWidth = Math.max(1, baseLw * 0.85);
+ ctx.shadowBlur = 0;
+ pathBoard();
+ ctx.stroke();
+ } else {
+ ctx.lineWidth = baseLw;
+ ctx.shadowColor = neon;
+ ctx.shadowBlur = 14;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 8;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ const attachX = signX + w / 2;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = textCol;
+ const lh = lineH || 12;
+ if (!simplePreviewPlaque && lines && lines.length) {
+ ctx.shadowColor = 'rgba(0,0,0,0.55)';
+ ctx.shadowBlur = 3;
+ }
+ if (lines && lines.length) {
+ const n = lines.length;
+ const textBlockH = n * lh;
+ const y0 = signY + Math.max(padY, (h - textBlockH) / 2);
+ const firstLineCenterY = y0 + lh / 2;
+ for (let li = 0; li < n; li++) {
+ ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh);
+ }
+ }
+ ctx.shadowBlur = 0;
+ if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)';
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.shadowBlur = 0;
+ ctx.beginPath();
+ ctx.moveTo(tether.x0, tether.y0);
+ ctx.lineTo(tether.x1, tether.y1);
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based
+ */
+ function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null;
+ const q = quizCarryCurrent;
+ const choices = q.choices;
+ if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null;
+ const i = idx0Based;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) return null;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ let signX;
+ let signY;
+ let w;
+ let h;
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ signX = sx - minW / 2;
+ signY = sy - minH / 2;
+ w = minW;
+ h = minH;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ const line = choiceText;
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ let maxW = Math.max(56, tileSpanX * ts - 14);
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(maxLineW + padX * 2);
+ h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+
+ /**
+ * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor)
+ * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด
+ */
+ function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) {
+ if (!isQuizCarry() || !mapData) return;
+ if (mapData.carryEmbedCountdownHighlight === false) return;
+ const optIdx = getQuizCarryLocalGrabbableOptionIndex();
+ if (optIdx == null) return;
+ const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx);
+ if (!pr || pr.w < 4 || pr.h < 4) return;
+ const rx = pr.signX;
+ const ry = pr.signY;
+ const rw = pr.w;
+ const rh = pr.h;
+ const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14));
+ const colRaw = mapData.carryEmbedCountdownHighlightColor;
+ const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a';
+ const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180);
+ ctx.save();
+ function pathRr() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr);
+ else ctx.rect(rx, ry, rw, rh);
+ }
+ pathRr();
+ ctx.fillStyle = strokeBase;
+ ctx.globalAlpha = 0.12 * pulse;
+ ctx.fill();
+ for (let g = 6; g >= 1; g--) {
+ ctx.strokeStyle = strokeBase;
+ ctx.globalAlpha = (0.07 + g * 0.055) * pulse;
+ ctx.lineWidth = 2.5 + g * 4;
+ pathRr();
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 0.98;
+ ctx.strokeStyle = strokeBase;
+ ctx.lineWidth = 4;
+ ctx.shadowColor = strokeBase;
+ ctx.shadowBlur = 22 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 12 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ }
+
+ function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (!quizCarryOptionsPickableNow()) return;
+ const choices = quizCarryCurrent.choices;
+ if (!choices || !choices.length) return;
+ const maxSlots = choices.length;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ ctx.save();
+ for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
+ if (quizCarryOptionHeldByAnyone(i)) continue;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) continue;
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const lineH = fontPx * 1.2;
+ const padY = 8;
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
+ continue;
+ }
+ const line = choiceText;
+ let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14);
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */
+ while (fontPx > 8 && ctx.measureText(line).width > maxW) {
+ fontPx -= 1;
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ }
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(maxLineW + padX * 2);
+ let h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
+ continue;
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ let h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
+ }
+ ctx.restore();
+ }
+
+ function quizCarryHubCellPlay(md, tx, ty) {
+ const g = md && md.quizCarryHubArea;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryInteractiveCellPlay(md, tx, ty) {
+ const g = md && md.interactive;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryMapHasAnswerInteractive(md) {
+ if (!md || !md.interactive || !md.interactive.length) return false;
+ const h = md.height || 15, w = md.width || 20;
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
+ function quizCarryOptionIndexAtPlay(md, tx, ty) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g[ty]) return null;
+ const v = g[ty][tx];
+ if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
+ const idx = v - 1;
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1 || idx >= n) return null;
+ return idx;
+ }
+
+ /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
+ function quizCarryFootprintOverlapsHub(px, py) {
+ if (!mapData || !isQuizCarry()) return false;
+ const md = mapData;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
+ function quizCarryFootprintAdjacentToHub(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryHubCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtHub(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
+ }
+
+ function quizCarryFootprintOverlapsInteractive(md, px, py) {
+ if (!md) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ function quizCarryFootprintAdjacentToInteractive(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
+ }
+
+ /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
+ function quizCarryCanSubmitAnswerAt(md, px, py) {
+ if (!md) return false;
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ return quizCarryCanSubmitAtInteractiveZone(md, px, py);
+ }
+ return quizCarryCanSubmitAtHub(md, px, py);
+ }
+
+ /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
+ function formatQuizCarryQuestionHud(q) {
+ if (!q) return '';
+ return String(q.text || q.question || q.prompt || q.title || '').trim();
+ }
+
+ function quizCarryRestoreEmbedPhaseLabel() {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl || !mapData) return;
+ phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
+ ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
+ : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
+ }
+
+ function isQuizCarryEmbedCountdownBlockingMovement() {
+ if (!quizCarryEmbedMissionFlowActive()) return false;
+ const now = Date.now();
+ return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now);
+ }
+
+ function isQuizCarryEmbedLobbyBlockingMovement() {
+ return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive);
+ }
+
+ function quizCarryPregameHumanIds() {
+ const ids = [];
+ if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId));
+ others.forEach((_, id) => {
+ if (!isPreviewBotId(id)) ids.push(String(id));
+ });
+ return ids;
+ }
+
+ function quizCarryPregameBotCount() {
+ let n = 0;
+ others.forEach((_, id) => {
+ if (isPreviewBotId(id)) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (quizCarryLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameTotalPlayers() {
+ return quizCarryPregameHumanIds().length + quizCarryPregameBotCount();
+ }
+
+ function isMePlayHost() {
+ if (myId == null) return false;
+ if (playHostId == null) return true;
+ return String(playHostId) === String(myId);
+ }
+
+ function hideQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ function showQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ updateQuizCarryPregameHud();
+ }
+
+ function quizCarrySyncGuestReadyIfNeeded() {
+ if (!quizCarryPregameActive || myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (quizCarryLobbyReadyMap[sid]) return;
+ quizCarryLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true });
+ }
+
+ function updateQuizCarryPregameHud() {
+ if (!quizCarryPregameActive) return;
+ const st = document.getElementById('quiz-carry-pregame-status');
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
+ const num = quizCarryPregameReadyNumerator();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ if (st) {
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ }
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
+ if (primaryImg) {
+ primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
+ primaryImg.alt = humansReady ? 'START' : 'READY';
+ }
+ if (primary) {
+ primary.classList.toggle('is-start-phase', humansReady);
+ primary.classList.toggle('is-read-only', !isMePlayHost());
+ primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady);
+ primary.disabled = !isMePlayHost();
+ primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false'));
+ primary.title = isMePlayHost()
+ ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+ }
+
+ function beginQuizCarryEmbedPregame() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ quizCarryPregameActive = true;
+ quizCarryLobbyReadyMap = {};
+ quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; });
+ showQuizCarryPregameOverlay();
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request');
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+
+ function endQuizCarryEmbedPregameAndStart() {
+ if (!quizCarryPregameActive) return;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPickNextQuestion();
+ const nowEmb = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowEmb) && !(quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ }
+
+ function quizCarryOptionsPickableNow() {
+ if (!isQuizCarry() || !quizCarryCurrent) return true;
+ /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return false;
+ if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
+ return Date.now() >= quizCarryOptionRevealAt;
+ }
+
+ function quizCarryApplyPhaseTimersForCurrentQuestion() {
+ if (!quizCarryCurrent) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ return;
+ }
+ const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ if (readMs <= 0) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ } else {
+ quizCarryOptionRevealAt = t0 + readMs;
+ quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
+ }
+ }
+
+ /** embed: หลัง 3–2–1 ก่อนหยิบ — เปิดป้ายตัวเลือกทันที + จับเวลาตอบ carryAnswerMs */
+ function quizCarryApplyEmbedAnswerWindowAfterPreOption321() {
+ if (!quizCarryCurrent) return;
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ }
+
+ function updateQuizCarryCarryPhaseHud() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !quizCarryCurrent) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) return;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl) return;
+ if (quizCarryAnswerTimeupAwaitNext && quizCarryAnswerRoundTimeupAdvanceAt > 0) {
+ const sec = Math.max(0, Math.ceil((quizCarryAnswerRoundTimeupAdvanceAt - Date.now()) / 1000));
+ phaseEl.textContent = 'หมดเวลาตอบ — ไปข้อถัดไปใน ' + sec + ' วิ…';
+ return;
+ }
+ const now = Date.now();
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > 0 && now < quizCarryEmbedPreOptionCountdownEndAt) {
+ const sec = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - now) / 1000));
+ phaseEl.textContent = 'เตรียมหยิบคำตอบ — นับ 3·2·1 (' + sec + ' วิ)';
+ return;
+ }
+ if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
+ const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
+ phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
+ return;
+ }
+ if (quizCarryAnswerCloseAt > now) {
+ const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
+ phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
+ return;
+ }
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+
+ function tickQuizCarryRoundTimers() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (quizCarryEmbedCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPendingQuestion) return;
+ if (quizCarryAnswerTimeupAwaitNext) return;
+ if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
+ if (Date.now() < quizCarryAnswerCloseAt) return;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryOptionRevealAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (isDetectiveMinigamePlay()) {
+ quizCarryAfterRoundResolved();
+ return;
+ }
+ quizCarryAnswerTimeupAwaitNext = true;
+ showQuizCarryAnswerRoundTimeupOverlay();
+ updateQuizCarryCarryPhaseHud();
+ }
+
+ function resetQuizCarryEmbedCountdownOverlayInners() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = 'คำถาม · Question';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ /** นับ 3–2–1: ซ่อนคำถามและหัวข้อ — เหลือแค่รูปเลขจาก /Game/img/QUESTION */
+ function showQuizCarryEmbedCountdownDigitsOnlyChrome() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = '';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ function hideQuizCarryEmbedCountdownOverlay() {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ resetQuizCarryEmbedCountdownOverlayInners();
+ }
+
+ function tickQuizCarryEmbedCountdown() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ const now = Date.now();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+
+ if (quizCarryCurrent && quizCarryEmbedPreOptionCountdownEndAt > 0) {
+ if (!numEl) return;
+ if (now >= quizCarryEmbedPreOptionCountdownEndAt) {
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ quizCarryApplyEmbedAnswerWindowAfterPreOption321();
+ quizCarryRestoreEmbedPhaseLabel();
+ syncQuizCarryEmbedQuestionStrip();
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsedPre = now - quizCarryEmbedPreOptionCountdownStartAt;
+ const nPre = 3 - Math.min(2, Math.floor(elapsedPre / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, nPre)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+
+ if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
+ if (now >= quizCarryEmbedCountdownEndAt) {
+ const pq = quizCarryEmbedPendingQuestion;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ if (!pq) return;
+ quizCarryCurrent = pq;
+ preloadQuizCarryChoiceImages(pq);
+ playQuizText = formatQuizCarryQuestionHud(pq);
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ quizCarryRestoreEmbedPhaseLabel();
+ others.forEach((o) => {
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ syncQuizCarryEmbedQuestionStrip();
+ quizCarryEmbedPreOptionCountdownStartAt = Date.now();
+ quizCarryEmbedPreOptionCountdownEndAt = quizCarryEmbedPreOptionCountdownStartAt + 3000;
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsed = now - quizCarryEmbedCountdownStartAt;
+ const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, n)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+
+ function quizCarryPickNextQuestion() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!quizCarryPool.length) {
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
+ return;
+ }
+ let q;
+ let guard = 0;
+ do {
+ q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
+ guard++;
+ } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
+ if (quizCarryEmbedMissionFlowActive()) {
+ quizCarryEmbedPendingQuestion = q;
+ preloadQuizCarryChoiceImages(q);
+ quizCarryEmbedCountdownStartAt = Date.now();
+ quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryCurrent = null;
+ playQuizText = '';
+ clearQuizMapQuestionCarryFeedback();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = '…';
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
+ tickQuizCarryEmbedCountdown();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+ quizCarryCurrent = q;
+ preloadQuizCarryChoiceImages(q);
+ playQuizText = formatQuizCarryQuestionHud(q);
+ clearQuizMapQuestionCarryFeedback();
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ });
+ quizCarryApplyPhaseTimersForCurrentQuestion();
+ }
+
+ /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */
+ function quizCarryAfterRoundResolved() {
+ if (!isQuizCarry()) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) {
+ finishDetectiveMinigameAndReturnLobby();
+ return;
+ }
+ if (!quizCarryEmbedMissionFlowActive() && !previewMode) {
+ quizCarryPickNextQuestion();
+ return;
+ }
+ quizCarryRoundsCompleted++;
+ const lim = quizCarrySessionLength;
+ if (lim > 0 && quizCarryRoundsCompleted >= lim) {
+ showQuizCarrySessionCompleteOverlay();
+ return;
+ }
+ quizCarryPickNextQuestion();
+ }
+
+ function playQuizCarryAvatarUrlForMission(characterId) {
+ if (!characterId) return '';
+ return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
+ }
+
+ /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */
+ function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) {
+ if (!imgEl || !characterId) return;
+ let attempts = 0;
+ const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId);
+ const tick = () => {
+ attempts++;
+ const nowT = Date.now();
+ const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down');
+ if (!rawImg) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) {
+ if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts);
+ setTimeout(tick, 90);
+ return;
+ }
+ if (!rawImg.naturalWidth) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ const tint = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local'));
+ const isMePeer = (peerId != null && myId != null && String(peerId) === String(myId));
+ const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer);
+ if (out && out.tagName === 'CANVAS' && out.width > 0) {
+ try {
+ imgEl.src = out.toDataURL('image/png');
+ return;
+ } catch (e) { /* ต่อไปลอง walk หรือ URL */ }
+ }
+ if (out && out.src && out !== rawImg) {
+ imgEl.src = out.src;
+ return;
+ }
+ if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) {
+ setTimeout(tick, 120);
+ return;
+ }
+ imgEl.src = rawImg.src || fallbackUrl;
+ };
+ tick();
+ }
+
+ /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */
+ function quizCarryGradeFromTeamAverage(avgRounded) {
+ if (avgRounded >= 80) return 'A';
+ if (avgRounded >= 60) return 'B';
+ return 'C';
+ }
+
+ function quizCarryRollCardRewardForGrade(grade) {
+ const u = Math.random() * 100;
+ if (grade === 'A') {
+ if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C') {
+ if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ return null;
+ }
+
+ function quizCarryBuildMissionRankList() {
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ characterId: me.characterId || getPlayCharacterId(),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ characterId: o.characterId || null,
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ return ranks.slice(0, 5);
+ }
+
+ /** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */
+ function quizCarryBuildGcmMissionPayload(opts) {
+ opts = opts || {};
+ const ranks = quizCarryBuildMissionRankList();
+ const ranked = ranks.map(function (row, idx) {
+ const pos = idx + 1;
+ const sc = Math.max(0, Number(row.score) || 0);
+ return {
+ id: row.id, nickname: row.nickname, characterId: row.characterId,
+ baseScore: sc, eliminated: false,
+ rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0, finalScore: sc,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.min(100, Math.max(0, Math.round(totalSum / n)));
+ const grade = opts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(averageScore);
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked, totalSum: totalSum, averageScore: averageScore,
+ grade: grade, rewardCard: rewardCard, totalParts: totalParts,
+ uiSkin: 'quiz_carry', survivorCount: opts.forceGradeF ? 0 : ranked.length,
+ participantCount: ranked.length,
+ };
+ }
+
+ /** ตั้งรูปพื้นหลังหน้าจบของ overlay กลางเป็นชุด quiz-carry (cut) */
+ function applyQuizCarryGcmMissionBg() {
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = BASE + '/img/quiz-carry/popup-result.png';
+ resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; };
+ }
+ }
+
+ function cancelEmbedPreviewLobbyReturnTimer() {
+ if (embedPreviewLobbyReturnTimer != null) {
+ clearTimeout(embedPreviewLobbyReturnTimer);
+ embedPreviewLobbyReturnTimer = null;
+ }
+ }
+
+ function cancelQuizCarryResultEndAfterTimeup() {
+ if (quizCarryResultEndTimer != null) {
+ clearTimeout(quizCarryResultEndTimer);
+ quizCarryResultEndTimer = null;
+ }
+ cancelEmbedPreviewLobbyReturnTimer();
+ }
+
+ function cancelQuizCarrySessionCompleteResultToSummary() {
+ if (quizCarrySessionCompleteResultToSummaryT != null) {
+ clearTimeout(quizCarrySessionCompleteResultToSummaryT);
+ quizCarrySessionCompleteResultToSummaryT = null;
+ }
+ }
+
+ function cancelQuizCarryAnswerRoundTimeupAuto() {
+ if (quizCarryAnswerRoundTimeupAutoT != null) {
+ clearTimeout(quizCarryAnswerRoundTimeupAutoT);
+ quizCarryAnswerRoundTimeupAutoT = null;
+ }
+ quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ }
+
+ function quizCarrySetAnswerRoundTimeupVisualActive(on) {
+ try {
+ if (on) document.documentElement.classList.add('quiz-carry-answer-timeup-active');
+ else document.documentElement.classList.remove('quiz-carry-answer-timeup-active');
+ } catch (e) { /* ignore */ }
+ }
+
+ /** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */
+ function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ if (!(previewMode && editorEmbedReturn)) return;
+ embedPreviewLobbyReturnTimer = setTimeout(function () {
+ embedPreviewLobbyReturnTimer = null;
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }, 5000);
+ }
+
+ /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */
+ function quizCarryAnyPlayerHasCorrectAnswer() {
+ if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false;
+ const keys = Object.keys(playLiveQuizScores);
+ for (let i = 0; i < keys.length; i++) {
+ if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true;
+ }
+ return false;
+ }
+
+ function hideQuizCarryResultEndLayer() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ function showQuizCarryResultEndLayer(useComplete) {
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ const img = document.getElementById('quiz-carry-result-end-img');
+ if (!el || !img) return;
+ const fname = useComplete ? 'result-complete.png' : 'result-gameover.png';
+ if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl(fname) + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ }
+
+ /** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */
+ function scheduleEmbedDeskResultAfterDelay(okResolver) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryResultEndLayer();
+ const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; };
+ quizCarryResultEndTimer = setTimeout(function () {
+ quizCarryResultEndTimer = null;
+ let anyOk = false;
+ try {
+ anyOk = !!resolve();
+ } catch (e) {
+ anyOk = false;
+ }
+ hideQuizCarryTimeupOnDeskLayer();
+ showQuizCarryResultEndLayer(anyOk);
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ }, 5000);
+ }
+
+ function hideQuizCarryTimeupOnDeskLayer() {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) {
+ quizCarryAnswerTimeupAwaitNext = false;
+ return;
+ }
+ el.classList.remove('qc-timeup-desk--interactive');
+ quizCarryAnswerTimeupAwaitNext = false;
+ const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner');
+ if (anchor) {
+ anchor.style.left = '';
+ anchor.style.top = '';
+ anchor.style.width = '';
+ anchor.style.height = '';
+ }
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ /** หมดเวลาตอบ (carryAnswerMs) — โชว์ TIME'S UP บนโซนคำถาม ~3s แล้วไปข้อถัดไป (ไม่ใช่ flow 5s → result ของ embed จบภารกิจ) */
+ function showQuizCarryAnswerRoundTimeupOverlay() {
+ if (!isQuizCarry()) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ quizCarrySetAnswerRoundTimeupVisualActive(true);
+ quizCarryAnswerRoundTimeupAdvanceAt = Date.now() + 3000;
+ el.classList.add('qc-timeup-desk--interactive');
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ quizCarryAnswerRoundTimeupAutoT = setTimeout(function () {
+ quizCarryAnswerRoundTimeupAutoT = null;
+ dismissQuizCarryAnswerRoundTimeupAndContinue();
+ }, 3000);
+ }
+
+ function dismissQuizCarryAnswerRoundTimeupAndContinue() {
+ if (!quizCarryAnswerTimeupAwaitNext) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ quizCarryAfterRoundResolved();
+ }
+
+ /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน
+ * @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */
+ function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ // Last Light (Gauntlet Crown): skip TIME'S UP desk graphic; keep embed 5s → result flow.
+ if (isGauntletCrownHeistMapPlay()) {
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(
+ typeof embedResultOkFn === 'function' ? embedResultOkFn : quizCarryAnyPlayerHasCorrectAnswer
+ );
+ }
+ return;
+ }
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer);
+ }
+ }
+
+ function showQuizCarryMissionSummaryOverlay(opts) {
+ opts = opts || {};
+ const forceF = !!opts.forceGradeF;
+ const overlay = document.getElementById('quiz-carry-mission-overlay');
+ if (!overlay) return;
+ hideQuizCarryTimeupOnDeskLayer();
+ const ranks = quizCarryBuildMissionRankList();
+ const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0));
+ const total = parts.reduce((a, b) => a + b, 0);
+ const n = ranks.length;
+ const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0;
+ const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade);
+ const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade);
+
+ const rowEl = document.getElementById('qc-mission-rank-row');
+ const totalEl = document.getElementById('qc-mission-total-line');
+ const gradeEl = document.getElementById('qc-mission-grade');
+ const cardTextEl = document.getElementById('qc-mission-card-text');
+ const checkEl = document.getElementById('qc-mission-check');
+ const successTxt = document.getElementById('qc-mission-success-txt');
+ const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]'];
+ if (rowEl) {
+ rowEl.innerHTML = '';
+ ranks.forEach((r, idx) => {
+ const rank = idx + 1;
+ const cell = document.createElement('div');
+ cell.className = 'qc-mission-rank-cell';
+ const tag = document.createElement('div');
+ tag.className = 'qc-mission-rank-tag';
+ tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']');
+ const frame = document.createElement('div');
+ frame.className = 'qc-mission-rank-frame';
+ const img = document.createElement('img');
+ img.className = 'qc-mission-rank-avatar';
+ img.alt = '';
+ applyQuizCarryRankAvatarTint(img, r.characterId, r.id);
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = 'data:image/svg+xml,' + encodeURIComponent('? ');
+ };
+ frame.appendChild(img);
+ const nick = document.createElement('div');
+ nick.className = 'qc-mission-rank-nick';
+ nick.textContent = r.nickname;
+ nick.title = r.nickname;
+ const sc = document.createElement('div');
+ sc.className = 'qc-mission-rank-score';
+ sc.textContent = String(r.score);
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ }
+ if (totalEl) {
+ totalEl.textContent = '';
+ if (!n) {
+ totalEl.textContent = '—';
+ } else {
+ const lab = document.createElement('span');
+ lab.textContent = 'คะแนนรวม ';
+ const nums = document.createElement('span');
+ nums.className = 'qc-mission-total-nums';
+ nums.textContent = '(' + parts.join('+') + ') = ' + total;
+ const tail = document.createElement('span');
+ tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100';
+ totalEl.appendChild(lab);
+ totalEl.appendChild(nums);
+ totalEl.appendChild(tail);
+ }
+ }
+ if (gradeEl) {
+ gradeEl.textContent = grade;
+ gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase();
+ }
+ const okMission = grade !== 'F';
+ if (checkEl) checkEl.style.display = okMission ? '' : 'none';
+ if (successTxt) {
+ successTxt.style.display = okMission ? '' : 'none';
+ if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ';
+ }
+ if (cardTextEl) {
+ cardTextEl.textContent = '';
+ if (grade === 'F') {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = 'ไม่ได้รับการ์ด';
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = 'เกรด F';
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ } else if (reward) {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = reward.th;
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = reward.en;
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ }
+ /* การ์ดพิเศษจากชุดคำถาม (ได้มาจากการตอบคำถามพิเศษในฉาก) — แสดงเสมอถ้าได้รับ */
+ if (specialQuizAwardedCard) renderSpecialCardInBonus(cardTextEl, specialQuizAwardedCard);
+ }
+ overlay.classList.remove('is-hidden');
+ const btn = document.getElementById('btn-quiz-carry-mission-done');
+ if (btn) {
+ btn.onclick = function () {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ overlay.classList.add('is-hidden');
+ if (isDetectiveMinigamePlay()) {
+ finishDetectiveMinigameAndReturnLobby();
+ } else {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ };
+ }
+ }
+
+ function showQuizCarrySessionCompleteOverlay() {
+ if (quizCarrySessionEnded) return;
+ quizCarrySessionEnded = true;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ playQuizText = 'ครบชุดคำถามแล้ว';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
+ const missionOv = document.getElementById('quiz-carry-mission-overlay');
+ if (missionOv && quizCarryUseMissionSummaryOverlay) {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ hideQuizCarryResultEndLayer();
+ const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) };
+ const splashRanks = quizCarryBuildMissionRankList();
+ const splashParts = splashRanks.map((r) => Math.max(0, Number(r.score) || 0));
+ const splashTotal = splashParts.reduce((a, b) => a + b, 0);
+ const splashN = splashRanks.length;
+ const splashAvg = splashN ? Math.min(100, Math.max(0, Math.round(splashTotal / splashN))) : 0;
+ const splashGrade = summaryOpts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(splashAvg);
+ const useCompleteSplash = splashGrade !== 'F';
+ showQuizCarryResultEndLayer(useCompleteSplash);
+ quizCarrySessionCompleteResultToSummaryT = setTimeout(function () {
+ quizCarrySessionCompleteResultToSummaryT = null;
+ hideQuizCarryResultEndLayer();
+ /* ใช้หน้าจบ overlay กลาง (เหมือนเกมอื่น) skin quiz_carry แทน overlay เฉพาะเดิม */
+ showGauntletCrownMissionOverlay(quizCarryBuildGcmMissionPayload(summaryOpts));
+ }, QUIZ_CARRY_SESSION_END_SPLASH_MS);
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'เกมจบ · Game over';
+ msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ /** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */
+ function showQuizCarryToast(msg, ok) {
+ if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
+ window.__quizCarryToastT = null;
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.className = 'play-quiz-feedback';
+ el.textContent = '';
+ }
+
+ /**
+ * หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
+ * @returns {boolean} ทำ action สำเร็จหรือไม่
+ */
+ function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
+ opts = opts || {};
+ if (quizCarryPregameActive) return false;
+ if (quizCarrySessionEnded) return false;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(ox, oy);
+ const ent = actorId === myId ? me : others.get(actorId);
+ if (!ent) return false;
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
+ const correct = quizCarryCurrent.correctIndex;
+ let held = ent.quizCarryHeld;
+ if (held != null && canSubmitAnswer) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ ent.quizCarryHeld = null;
+ const ok = held === correct;
+ if (ok) {
+ playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
+ const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
+ const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
+ const showMapFb = !opts.silent && actorId === myId;
+ if (showMapFb) showQuizMapQuestionCarryFeedback(true);
+ if (!opts.silent) {
+ const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
+ showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
+ }
+ me.quizCarryHeld = null;
+ others.forEach((o) => { if (o) o.quizCarryHeld = null; });
+ const afterOk = () => { quizCarryAfterRoundResolved(); };
+ if (showMapFb) {
+ window.setTimeout(afterOk, 560);
+ } else {
+ afterOk();
+ }
+ } else {
+ if (!opts.silent && actorId === myId) {
+ showQuizMapQuestionCarryFeedback(false);
+ }
+ if (!opts.silent) {
+ showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
+ }
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return true;
+ }
+ if (held == null && pickupIdx != null) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ if (quizCarryOptionHeldByAnyone(pickupIdx)) {
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
+ }
+ return false;
+ }
+ ent.quizCarryHeld = pickupIdx;
+ const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
+ if (!opts.silent) {
+ showQuizCarryToast('หยิบ: ' + label, true);
+ }
+ return true;
+ }
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ const t = Date.now();
+ if (t - (me.quizCarryHintT || 0) > 2200) {
+ me.quizCarryHintT = t;
+ if (held != null && !canSubmitAnswer) {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
+ : 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
+ } else {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
+ : 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
+ }
+ }
+ }
+ return false;
+ }
+
+ function quizCarryGrabCoreGatesOk() {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ if (quizCarryPregameActive || quizCarrySessionEnded) return false;
+ if (myId == null) return false;
+ if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
+ if (!quizCarryOptionsPickableNow()) return false;
+ return true;
+ }
+
+ /** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */
+ function getQuizCarryLocalGrabbableOptionIndex() {
+ if (!quizCarryGrabCoreGatesOk()) return null;
+ if (me.quizCarryHeld != null) return null;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(me.x, me.y);
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0];
+ const ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null;
+ return pickupIdx;
+ }
+
+ /** ยืนบนช่องตัวเลือกและหยิบได้ */
+ function quizCarryGrabPickupAvailable() {
+ return getQuizCarryLocalGrabbableOptionIndex() != null;
+ }
+
+ /** ถือป้ายแล้วยืนโซนส่งได้ */
+ function quizCarryGrabSubmitAvailable() {
+ if (!quizCarryGrabCoreGatesOk()) return false;
+ if (me.quizCarryHeld == null) return false;
+ return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
+ }
+
+ /** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
+ function quizCarryGrabInteractionAvailable() {
+ return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
+ }
+
+ function syncQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ if (!isQuizCarry() || !mapData) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('quiz-carry-grab-btn--active');
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.setAttribute('aria-disabled', 'true');
+ return;
+ }
+ const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
+ const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
+ const img = btn.querySelector('img');
+ btn.classList.remove('is-hidden');
+ const active = quizCarryGrabInteractionAvailable();
+ btn.classList.toggle('quiz-carry-grab-btn--active', active);
+ btn.setAttribute('aria-disabled', active ? 'false' : 'true');
+ btn.style.pointerEvents = active ? '' : 'none';
+ if (img) {
+ if (active && quizCarryGrabSubmitAvailable()) {
+ img.src = placeSrc;
+ btn.classList.add('quiz-carry-grab-btn--place');
+ btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
+ btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
+ } else {
+ img.src = grabSrc;
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.title = 'หยิบตัวเลือก — เหมือนกด F';
+ btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
+ }
+ }
+ }
+
+ /** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */
+ function tryRequestGauntletJumpPlay() {
+ if (!mapData || !isGauntlet() || isChatFocused()) return false;
+ if (isGauntletCrownPregameBlockingPlay()) return false;
+ if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false;
+ const now = Date.now();
+ if (now - lastGauntletJumpKey < 200) return false;
+ lastGauntletJumpKey = now;
+ socket.emit('gauntlet-jump');
+ return true;
+ }
+
+ function syncGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ /* MG5 jump_survive (jumper): โชว์ปุ่ม JUMP มุมขวาล่าง (design .mg5-jump 1648/818/231) — ให้ touch กระโดดได้ (เดิม keyboard-only) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) {
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ btn.classList.add('mg2-jump');
+ const jimg = btn.querySelector('img');
+ if (jimg && jimg.src.indexOf('/Jumper/') < 0) jimg.src = jumperAssetUrl('btn-jump.png');
+ const hudJ = document.getElementById('play-cyber-hud');
+ if (hudJ && hudJ.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hudJ);
+ syncMg2MockJumpButtonBoundsPlay();
+ return;
+ }
+ const pregameUi = gauntletCrownPregamePhase === 'howto' || gauntletCrownPregamePhase === 'countdown';
+ const liveUi = gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const show = !!(mapData && isGauntletCrownHeistMapPlay() && !me.gauntletEliminated && (pregameUi || liveUi));
+ if (!show) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('mg2-jump');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ const mg2Jump = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ btn.classList.toggle('mg2-jump', !!mg2Jump);
+ if (mg2Jump) {
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hud);
+ syncMg2MockJumpButtonBoundsPlay();
+ } else {
+ btn.classList.remove('mg2-jump');
+ }
+ }
+
+ function syncStackTowerDropButtonPlay() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ if (!mapData || !isStack() || !stackMini) {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ const ok = stackHumanDropGateOkPlay();
+ btn.classList.toggle('stack-tower-drop-btn--active', ok);
+ btn.style.pointerEvents = ok ? '' : 'none';
+ }
+
+ /** MG3 (mnn93hpi): ผู้เล่นตาปัจจุบัน (active node) สำหรับ host portrait ใน DOM HUD */
+ function mg3CurrentSeatRowPlay() {
+ const seat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ let entry = null;
+ if (order && order.length) entry = order.find((e) => e.seat === seat) || null;
+ if (!entry || entry.kind === 'human') {
+ return {
+ id: myId,
+ characterId: me.characterId || getPlayCharacterId(),
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ id: entry.botId,
+ characterId: o ? o.characterId : null,
+ isMe: false,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ /**
+ * MG3 Stack Tower live HUD → DOM overlay (#mg3-tower-hud) — canvas object-fit:cover ครอป HUD ขอบจอ
+ * จึงย้าย life bar (ขวาบน) + progress (ซ้ายล่าง) + code terminal + host frame มาเป็น DOM สเกลตาม stage
+ */
+ function syncStackTowerMissionDomHudPlay() {
+ const root = document.getElementById('mg3-tower-hud');
+ if (!root) return;
+ const on = !!(mapData && isStack() && stackMini && isStackTowerMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+
+ if (!root.dataset.mg3Assets) {
+ const setSrc = (sel, file) => { const el = root.querySelector(sel); if (el) el.src = stackTowerAssetUrl(file); };
+ setSrc('.mg3-life-bar', 'life-bar.png');
+ setSrc('.mg3-host-frame', 'Avartar.png');
+ setSrc('.mg3-terminal-bg', 'code-bg.png');
+ setSrc('.mg3-progress-label', 'progress-txt.png');
+ setSrc('.mg3-progress-track', 'progress-bg.png');
+ setSrc('.mg3-progress-fill', 'progress.png');
+ root.dataset.mg3Assets = '1';
+ }
+
+ /* วาง design plane 1920×1080 → scale แบบ contain + จัดกึ่งกลางใน stage (HUD เห็นครบ ไม่โดน object-fit:cover ครอป) */
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.mg3Box !== key) {
+ root.dataset.mg3Box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+
+ const slots = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ const lives = Math.max(0, Math.min(slots, Math.floor(Number(
+ stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : (stackMini.lives != null ? stackMini.lives : 3),
+ ) || 0)));
+ const heartsEl = document.getElementById('mg3-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + lives;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = stackTowerAssetUrl(i < lives ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+
+ const pct = Math.max(0, Math.min(100, Math.round(Number(stackTowerDecryptPctPlay()) || 0)));
+ if (root.dataset.mg3Pct !== String(pct)) {
+ root.dataset.mg3Pct = String(pct);
+ /* fill เป็น px ในระนาบ design (track ภายในจาก left:30 กว้างสุด ~358px) */
+ const fillEl = document.getElementById('mg3-progress-fill');
+ if (fillEl) fillEl.style.width = Math.round(358 * pct / 100) + 'px';
+ const pctEl = document.getElementById('mg3-progress-pct');
+ if (pctEl) pctEl.textContent = '[ ' + pct + '% ]';
+ }
+
+ const hostAv = document.getElementById('mg3-host-av');
+ if (hostAv) {
+ const row = mg3CurrentSeatRowPlay();
+ const hk = (row.id != null ? row.id : 'me') + '|' + (row.characterId || '');
+ if (hostAv.dataset.hk !== hk) {
+ hostAv.dataset.hk = hk;
+ setCyberHudScoreAvatarImg(hostAv, row);
+ }
+ }
+
+ const logEl = document.getElementById('mg3-terminal-log');
+ if (logEl) {
+ const towerLiveNow = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (towerLiveNow && (!mg3TermLog || !mg3TermLog.length)) mg3TermBoot();
+ else if (!towerLiveNow && mg3TermLog && mg3TermLog.length) mg3TermReset();
+ let lines;
+ if (mg3TermLog && mg3TermLog.length) lines = mg3TermLog.slice(-9);
+ else if (stackPreviewHudLog && stackPreviewHudLog.length) lines = stackPreviewHudLog.slice(-7);
+ else lines = ['>>> DECRYPT UPLINK READY', '>>> SPACE / CLICK = DROP (ตาคุณ)'];
+ const txt = lines.join('\n');
+ if (logEl.textContent !== txt) logEl.textContent = txt;
+ }
+ }
+
+ /**
+ * MG6 Space Shooter life → overlay #mg6-life-hud (ระนาบ design 1920×1080 scale ตาม stage แบบเดียวกับ MG3)
+ * life-bar.png + หัวใจ 3 ดวง (ViolentCrime) ตำแหน่ง/ขนาดเท่า MG3 — ไม่พึ่ง flex ของ cyber HUD (กันบีบ/ล้นขอบ)
+ */
+ function syncSpaceShooterLifeDomHudPlay() {
+ const root = document.getElementById('mg6-life-hud');
+ if (!root) return;
+ const on = !!(mapData && isSpaceShooter() && isSpaceShooterMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ if (!root.dataset.assets) {
+ const bar = root.querySelector('.mg6-life-bar');
+ if (bar) bar.src = violentCrimeAssetUrl('life-bar.png');
+ root.dataset.assets = '1';
+ }
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const slots = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS;
+ const hits = Math.max(0, Math.min(slots, Number(me.spaceShooterHits) || 0));
+ const rem = slots - hits;
+ const heartsEl = document.getElementById('mg6-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + rem;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = violentCrimeAssetUrl(i < rem ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ im.decoding = 'async';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+ }
+
+ /** Quiz Battle (qbroom*) เลขห้องจาก mapId → tag/หมวด */
+ function quizBattleRoomNoPlay() {
+ const m = String(currentPlayMapId() || '').match(/qbroom(\d+)/i);
+ return m ? Math.max(1, Math.min(10, parseInt(m[1], 10))) : 0;
+ }
+
+ /**
+ * Quiz Battle in-game HUD → overlay #qb-game-hud (design plane 1920×1080 scale ตาม stage)
+ * header หมวด (tag-NN), PLAYERS x/50, SCORE (ตอบถูก), host avatar. EXIT/MY RESULT/RANKING wire แยก
+ */
+ function syncQuizBattleHudPlay() {
+ const root = document.getElementById('qb-game-hud');
+ if (!root) return;
+ const on = !!(mapData && isQuizBattle());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ /* ซ่อนกล่อง dev instruction ('QUIZ BATTLE · โหลดคำถามแล้ว...') มุมซ้ายล่าง — ไม่มีใน design */
+ const qgOv = document.getElementById('quiz-game-overlay');
+ if (qgOv && !qgOv.classList.contains('is-hidden')) qgOv.classList.add('is-hidden');
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const roomNo = quizBattleRoomNoPlay();
+ const tag = document.getElementById('qb-hud-tag');
+ if (tag && roomNo > 0) {
+ const want = BASE + '/img/qb-ui/tag-' + (roomNo < 10 ? '0' + roomNo : String(roomNo)) + '.png';
+ if (tag.dataset.src !== want) { tag.dataset.src = want; tag.src = want; }
+ }
+ const pv = document.getElementById('qb-hud-players-val');
+ if (pv) {
+ const n = countPlayHumans();
+ const max = (mapData && mapData.maxPlayers) || 50;
+ const txt = n + '/' + max;
+ if (pv.textContent !== txt) pv.textContent = txt;
+ }
+ const sv = document.getElementById('qb-hud-score-val');
+ if (sv) {
+ const sc = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ if (sv.textContent !== String(sc)) sv.textContent = String(sc);
+ }
+ const av = document.getElementById('qb-hud-host-av');
+ if (av) {
+ const cid = me.characterId || getPlayCharacterId();
+ if (cid && av.dataset.cid !== String(cid)) {
+ av.dataset.cid = String(cid);
+ /* full-body down avatar (เหมือนหน้า Quiz-Battle/.qb-room-profile-avatar) clip โชว์แค่ส่วนบน */
+ ensurePlayLobbyIdleAvatarImage(cid);
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) {
+ av.src = src;
+ } else {
+ const im = getCharacterImg(cid, 'down');
+ if (im && im.src) av.src = im.src;
+ }
+ }
+ }
+ }
+
+ function resetQuizCarryPlayState() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ const qcmo = document.getElementById('quiz-carry-mission-overlay');
+ if (qcmo) qcmo.classList.add('is-hidden');
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPool = [];
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryAnswerTimeupAwaitNext = false;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryEmbedCountdownOverlay();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ }
+
+ /** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
+ async function mergeQuizCarrySettingsFromDisk(s) {
+ const out = s && typeof s === 'object' ? s : {};
+ try {
+ const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
+ if (rd.ok) {
+ const disk = await rd.json();
+ if (disk && typeof disk === 'object') {
+ if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
+ out.carryMapPanelTheme = disk.carryMapPanelTheme;
+ }
+ if (disk.quizMapPanelTheme && typeof disk.quizMapPanelTheme === 'object') {
+ out.quizMapPanelTheme = disk.quizMapPanelTheme;
+ }
+ if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
+ out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
+ }
+ if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
+ out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
+ } else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
+ out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
+ }
+ if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
+ if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
+ if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
+ if (disk.carryWalkSpeedMultForMapId != null) {
+ out.carryWalkSpeedMultForMapId = String(disk.carryWalkSpeedMultForMapId).trim();
+ }
+ if (disk.carryWalkSpeedMult != null) {
+ const wm = Number(disk.carryWalkSpeedMult);
+ if (Number.isFinite(wm)) out.carryWalkSpeedMult = wm;
+ }
+ const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
+ if (Number.isFinite(diskPlaqueScale)) {
+ out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
+ }
+ if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
+ out.carryQuestions = disk.carryQuestions;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ return out;
+ }
+
+ async function loadQuizCarryPoolAndStart() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ quizCarrySessionLength = 0;
+ let pool = [];
+ let s = {};
+ const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === 'object') s = parsed;
+ }
+ }
+ } catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ }
+ try {
+ s = await mergeQuizCarrySettingsFromDisk(s);
+ } catch (e) { /* ignore */ }
+ if (snap) {
+ if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs;
+ if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs;
+ if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength;
+ }
+ if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') {
+ s.carryMapPanelTheme = snap.carryMapPanelTheme;
+ }
+ if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') {
+ s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme;
+ }
+ /* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */
+ if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) {
+ if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) {
+ s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes;
+ } else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') {
+ s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme;
+ }
+ }
+ if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) {
+ const sm = Number(snap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm;
+ }
+ if (snap) {
+ if ((s.carryWalkSpeedMultForMapId == null || String(s.carryWalkSpeedMultForMapId).trim() === '') && snap.carryWalkSpeedMultForMapId != null) {
+ s.carryWalkSpeedMultForMapId = snap.carryWalkSpeedMultForMapId;
+ }
+ if (s.carryWalkSpeedMult == null && snap.carryWalkSpeedMult != null) s.carryWalkSpeedMult = snap.carryWalkSpeedMult;
+ }
+ applyQuizCarryWalkSpeedFromSettingsObj(s);
+ {
+ const sc = Number(s.carryChoicePlaqueMapScale);
+ quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25;
+ }
+ try {
+ quizCarryCarryTimingMs = {
+ carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
+ carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
+ };
+ quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
+ setQuizCarryMapPanelThemeFromApi(s);
+ setQuizCarryEmbedCountdownThemeFromApi(s);
+ setQuizCarryChoicePlaqueThemeFromApi(s);
+ for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) {
+ const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl);
+ if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue;
+ const pim = new Image();
+ quizCarryApplyImageCrossOrigin(pim, tUrl);
+ pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(tUrl, pim);
+ try {
+ pim.src = tUrl;
+ } catch (e) { /* ignore */ }
+ }
+ const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
+ const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
+ for (let i = 0; i < source.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(source[i]);
+ if (n) pool.push(n);
+ }
+ } catch (e) { /* map only */ }
+ if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
+ quizCarryPool = pool;
+ for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]);
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (!pool.length) {
+ playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ return;
+ }
+ if (quizCarryEmbedMissionFlowActive()) {
+ beginQuizCarryEmbedPregame();
+ } else {
+ quizCarryPickNextQuestion();
+ }
+ const nowJoin = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowJoin) && !(quizCarryEmbedPreOptionCountdownEndAt > nowJoin)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ }
+
+ function setupPlayQuizCarryUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ } else {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ }
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = isDetectiveMinigamePlay()
+ ? ''
+ : (quizCarryMapHasAnswerInteractive(mapData)
+ ? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
+ : 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย');
+ }
+ resetQuizCarryPlayState();
+ if (previewMode) {
+ loadQuizCarryPoolAndStart();
+ } else {
+ playQuizText = isDetectiveMinigamePlay() ? '' : 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
+ loadQuizCarryPoolAndStart();
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ syncQuizCarryGrabButton();
+ }
+
+ function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const want = optionIndex + 1;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryHub(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryInteractive(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive && md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileForQuizCarrySubmit(md, o) {
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ const p = pickRandomTileInQuizCarryInteractive(md, o);
+ if (p) return p;
+ }
+ return pickRandomTileInQuizCarryHub(md, o);
+ }
+
+ /** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */
+ function quizCarryPreviewBotLobbyWanderStep(o, w, h) {
+ const now = Date.now();
+ o.botIsWalking = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED * quizCarryWalkSpeedMultActive;
+ const nx = o.x + accX * step;
+ const ny = o.y + accY * step;
+ const ox = o.x;
+ const oy = o.y;
+ if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ }
+
+ function stepQuizCarryPreviewBots() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!playBotsEnabled() || !isQuizCarry() || !mapData) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const nowPf = performance.now();
+ const pathfindMinGap = editorEmbedReturn ? 280 : 140;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
+ const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
+ if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
+ o.x = sn.x;
+ o.y = sn.y;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ if (!o.botPath || o.botPath.length === 0) {
+ tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
+ }
+ /* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */
+ if (!quizCarryCurrent) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const tier = o.botTier || 'avg';
+ const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
+ const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
+ : Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
+ const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
+ if (!o.botPath || !o.botPath.length) {
+ if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
+ if (nowPf < o.botQuizCarryPathfindAfter) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
+ if (quizCarryOptionsPickableNow()) {
+ if (o.quizCarryHeld == null && wantIdx != null) {
+ const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
+ if (dest) {
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ } else {
+ const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
+ if (sub) {
+ const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ }
+ o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
+ } else {
+ o.botQuizCarryPathfindAfter = nowPf + 120;
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ }
+ } else {
+ stepPreviewBotAlongPath(o, w, h);
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ }
+
+ function getStackAreaBoundsPlay(area, w, h) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let any = false;
+ for (let y = 0; y < h; y++) {
+ const row = area && area[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) {
+ any = true;
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ if (!any) return null;
+ return {
+ minX, maxX, minY, maxY,
+ cx: (minX + maxX + 1) / 2,
+ cy: (minY + maxY + 1) / 2,
+ };
+ }
+
+ /** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */
+ function getStackTowerCameraFollowPlayConfig() {
+ return { enabled: false, pxPerLayer: 12, maxPx: 260 };
+ }
+
+ /** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */
+ function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) {
+ if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0;
+ const n = Math.floor(Number(layerCount) || 0);
+ if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0;
+ const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3));
+ const tiers = n - (STACK_TOWER_SWING_LIFT_FROM_LAYER - 1);
+ return Math.min(lh * 20, tiers * lh * 0.98);
+ }
+
+ /**
+ * progress ≥เกณฑ์ Tower + ชั้นพอ: ไม่วาดชั้นล่าง (ข้าม draw) — ตำแหน่ง Y ยังใช้ชั้นจริง (floorY-(i+1)*h)
+ * เพื่อไม่ให้กองลอยหลุดจากฐานแมปเมื่อเทียบกริด
+ */
+ function getStackTowerVisualHiddenLayerCountPlay() {
+ if (!isStackTowerMissionHudActivePlay() || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) return 0;
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ if (n < 4) return 0;
+ return Math.floor(n / 2);
+ }
+
+ /**
+ * world px ตามความสูงหอจริง (mnn93hpi) — คูณ z แล้วได้พิกเซลจอสำหรับเลื่อนแถบ BG
+ * ใช้ n × layerWorldH (ความสูงชั้นตามบล็อก) คล้มด้วย maxPx จากแมป
+ */
+ function getStackTowerBgScrollHeightBoostPx() {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const cfg = getStackTowerCameraFollowPlayConfig();
+ if (!cfg.enabled || cfg.pxPerLayer <= 0) return 0;
+ const lh = Math.max(10, Number(stackMini.layerWorldH) || Math.max(14, (tileSize || 32) * 0.31));
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ let layerCount = n;
+ if (stackFall) {
+ const dur = Math.max(1, stackFall.dur || 400);
+ const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur));
+ layerCount = n + u * 0.9;
+ }
+ return Math.min(cfg.maxPx, layerCount * lh);
+ }
+
+ /** กล้อง stack: จุดกลางจาก land / release ของแมป (Tower ไม่เลื่อน cy ตามกอง) */
+ function getStackCameraCentersPx() {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let cx = (w / 2) * ts;
+ let cy = (h / 2) * ts;
+ if (land && rel) {
+ cx = land.cx * ts;
+ cy = ((land.cy + rel.cy) / 2) * ts;
+ } else if (land) {
+ cx = land.cx * ts;
+ cy = land.cy * ts;
+ } else if (rel) {
+ cx = rel.cx * ts;
+ cy = rel.cy * ts;
+ }
+ return { px: cx, py: cy };
+ }
+
+ /** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
+ function reapplyStackMiniSizingFromGlobals() {
+ if (!mapData || !isStack() || !stackMini) return;
+ if (stackMini.layers && stackMini.layers.length > 0) return;
+ if (stackFall || stackMini.settling) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackMini.initialWidthTiles = iw;
+ stackMini.widthTiles = iw;
+ markStackNextDropVisualDirty();
+ }
+
+ function resetStackMinigameState() {
+ stackMini = null;
+ stackTowerPost50AnimStartMs = null;
+ stackTowerHeartMinusFx = null;
+ if (!mapData || !isStack()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let towerCX = w * 0.5;
+ let swingAmp = 1.85;
+ let floorWorldY = (h - 1) * tileSize;
+ let swingWorldY = floorWorldY - tileSize * 1.35;
+ if (land) {
+ towerCX = land.cx;
+ floorWorldY = (land.maxY + 1) * tileSize;
+ const span = Math.max(1, land.maxX - land.minX + 1);
+ swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
+ }
+ if (rel) {
+ swingWorldY = (rel.cy + 0.5) * tileSize;
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
+ }
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackFall = null;
+ const towerMission = isStackTowerMissionUiMapPlay();
+ const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul;
+ const missMax = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ stackMini = {
+ topCenterX: towerCX,
+ widthTiles: initW,
+ initialWidthTiles: initW,
+ craneWorldX: towerCX * tileSize,
+ swingAmp,
+ phase: Math.random() * Math.PI * 2,
+ phaseSpeed: playStackSwingHz,
+ floorWorldY,
+ swingWorldY,
+ layerWorldH: towerMission ? towerLayerStripH : Math.max(14, tileSize * 0.3),
+ blockStripH: towerMission ? towerLayerStripH : Math.max(16, tileSize * 0.32),
+ layers: [],
+ lives: 3,
+ teamMissesLeft: towerMission ? missMax : null,
+ progressPct: 0,
+ score: 0,
+ combo: 0,
+ lastDropAt: 0,
+ stackTiltRad: 0,
+ stackTiltVel: 0,
+ nextDropVisualDirty: true,
+ pendingDropSeat: 1,
+ pendingDropHeavy: false,
+ scorePopups: [],
+ perfectStackX2Fx: null,
+ };
+ if (playBotsEnabled()) {
+ me.stackPreviewHumanPts = 0;
+ stackPreviewHudLog = [];
+ }
+ ensureStackTowerSlingImagePlay();
+ if (towerMission || playBotsEnabled()) {
+ ensureStackTowerLifeHudImagesPlay();
+ ensureStackTowerHeartMinusImgPlay();
+ }
+ updateStackTowerSwingYForStripGapPlay();
+ }
+
+ function stackEaseDrop(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t;
+ }
+
+ /** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
+ function stackEaseDropPhys(t, sloppyN) {
+ t = Math.max(0, Math.min(1, t));
+ if (sloppyN < 0.14) return t * t;
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ }
+
+ /**
+ * @param {object} hit
+ * @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
+ */
+ function applyStackHitFromDrop(hit, previewAttribution, layerVisualMeta) {
+ if (!stackMini || !hit) return;
+ const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (hit.miss) {
+ stackMini.combo = 0;
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ if (stackMini.layers && stackMini.layers.length) {
+ for (let gi = 0; gi < stackMini.layers.length; gi++) {
+ const lay = stackMini.layers[gi];
+ if (lay && lay.perfectGlowUntil != null) delete lay.perfectGlowUntil;
+ }
+ }
+ if (towerLive && stackMini.teamMissesLeft != null) {
+ stackMini.teamMissesLeft = Math.max(0, stackMini.teamMissesLeft - 1);
+ } else {
+ stackMini.lives = Math.max(0, stackMini.lives - 1);
+ if (stackMini.lives <= 0) {
+ stackMini.score = Math.max(0, stackMini.score - 2);
+ stackMini.lives = 3;
+ stackMini.layers = [];
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ stackMini.widthTiles = stackMini.initialWidthTiles;
+ const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
+ stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
+ stackMini.combo = 0;
+ stackMini.stackTiltRad = 0;
+ stackMini.stackTiltVel = 0;
+ }
+ }
+ if (towerLive) {
+ triggerStackTowerHeartMinusFxPlay();
+ const remain = stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : Math.max(0, (stackMini.lives || 0));
+ mg3TermOnMiss(Number(remain) || 0);
+ }
+ } else {
+ const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat();
+ const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy);
+ const placedW = hit.placedW != null && Number.isFinite(hit.placedW)
+ ? Math.max(0.85, Math.min(3.2, hit.placedW))
+ : stackMini.initialWidthTiles;
+ stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay });
+ const nAfter = stackMini.layers.length;
+ maybeQueueStackTowerBgScrollStepPlay(nAfter);
+ const perfectFull =
+ towerLive &&
+ hit.perfect &&
+ !hit.miss &&
+ nAfter >= 2 &&
+ hit.supportRatio != null &&
+ hit.supportRatio >= STACK_TOWER_PERFECT_SUPPORT_MIN;
+ if (perfectFull) {
+ const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS;
+ stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil;
+ stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil;
+ }
+ if (towerLive && (perfectFull || hit.pts >= 20)) {
+ ensureStackTowerPerfectFxImagesPlay();
+ stackMini.perfectStackX2Fx = {
+ until: performance.now() + STACK_TOWER_PERFECT_X2_MS,
+ topLayerIndex: nAfter - 1,
+ };
+ } else if (towerLive) {
+ stackMini.perfectStackX2Fx = null;
+ }
+ stackMini.score += hit.pts;
+ if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) {
+ let np = Number(stackMini.progressPct) + Number(hit.progressDelta);
+ np = Math.min(100, Math.max(0, np));
+ if (np > 99.999) np = 100;
+ stackMini.progressPct = np;
+ markStackNextDropVisualDirty();
+ }
+ if (playBotsEnabled() && previewAttribution) {
+ if (previewAttribution.human) {
+ me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
+ } else if (previewAttribution.botId) {
+ const ob = others.get(previewAttribution.botId);
+ if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
+ }
+ }
+ stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
+ stackMini.topCenterX = hit.placedCx;
+ if (towerLive && !hit.miss && hit.pts > 0) {
+ const pNow = Number(stackMini.progressPct);
+ const prog100 = Number.isFinite(pNow) && pNow >= 99.5;
+ const kind20 = hit.pts >= 20 || prog100;
+ if (!stackMini.scorePopups) stackMini.scorePopups = [];
+ const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS;
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' });
+ if (kind20 && perfectFull && nAfter >= 2) {
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' });
+ }
+ while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift();
+ }
+ if (towerLive && !hit.miss) mg3TermOnPlace(perfectFull || !!hit.perfect, stackMini.progressPct);
+ }
+ }
+
+ /**
+ * Editor embed เท่านั้น: ปรับ `stackMini.phase` ให้ sin·swingAmp ใกล้จุดรองรับก่อนปล่อย — บอทฉลาด/กลาง/พลาดบ่อย ต่างค่า aim error
+ */
+ function previewBotAlignStackSwingPhasePlay(m, botPeer) {
+ if (!playBotsEnabled() || !m || !mapData) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg';
+ const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05);
+ const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
+ const tri = Math.random() + Math.random() - 1;
+ let errTiles = tri * errMax * layerTight;
+ if (m.layers.length >= 2) {
+ const L = m.layers.length;
+ const step = m.layers[L - 1].cx - m.layers[L - 2].cx;
+ errTiles -= step * 0.28;
+ }
+ let targetCx = supportCx + errTiles;
+ if (land && m.layers.length > 0) {
+ const pull = (land.cx - supportCx) * 0.1;
+ const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
+ targetCx += Math.max(-cap, Math.min(cap, pull));
+ }
+ const rawWant = targetCx - m.topCenterX;
+ const amp = Math.max(1e-5, m.swingAmp);
+ const s = Math.max(-1, Math.min(1, rawWant / amp));
+ const phi0 = Math.asin(s);
+ const phi1 = Math.PI - phi0;
+ const twoPi = Math.PI * 2;
+ function nearestPhase(anchor, p) {
+ let best = p;
+ let bestD = Infinity;
+ for (let k = -5; k <= 5; k++) {
+ const cand = p + k * twoPi;
+ const d = Math.abs(cand - anchor);
+ if (d < bestD) {
+ bestD = d;
+ best = cand;
+ }
+ }
+ return best;
+ }
+ const n0 = nearestPhase(m.phase, phi0);
+ const n1 = nearestPhase(m.phase, phi1);
+ m.phase = Math.abs(n0 - m.phase) <= Math.abs(n1 - m.phase) ? n0 : n1;
+ }
+
+ /**
+ * ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน)
+ * พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*)
+ */
+ function computeStackDropResult(m) {
+ const raw = m.swingAmp * Math.sin(m.phase);
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ const wMul = getStackTowerBlockWorldScalePlay();
+ const fallWEff = fallW * wMul;
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const c1 = m.topCenterX + raw;
+ const half1 = fallWEff * 0.5;
+ const half0 = (m.layers.length === 0 ? supportW : supportW * wMul) * 0.5;
+ const left = Math.max(supportCx - half0, c1 - half1);
+ const right = Math.min(supportCx + half0, c1 + half1);
+ const overlap = right - left;
+ const missThreshold =
+ m.layers.length === 0
+ ? Math.max(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND)
+ : Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER);
+ const overlapMiss = overlap < missThreshold;
+ const placedCx = c1;
+ let driftMiss = false;
+ if (!overlapMiss && m.layers.length > 0) {
+ const anchorCx = land ? land.cx : wMap * 0.5;
+ let maxDriftTiles;
+ if (land) {
+ const landSpan = Math.max(1, land.maxX - land.minX + 1);
+ maxDriftTiles = landSpan * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND;
+ } else {
+ maxDriftTiles = Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT);
+ }
+ if (Math.abs(placedCx - anchorCx) > maxDriftTiles) driftMiss = true;
+ }
+ const miss = overlapMiss || driftMiss;
+ const delta = c1 - supportCx;
+ const span = supportW * 0.5 + fallW * 0.5;
+ const offN = Math.abs(delta) / Math.max(0.01, span);
+ const perfect = !miss && offN < 0.055;
+ const comboBefore = m.combo || 0;
+ let pts;
+ let progressDelta = 0;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live') {
+ const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1;
+ const progPerLayer = 100 / stackTowerProgressBlocksPlay();
+ pts = miss ? 0 : (10 * mult);
+ progressDelta = miss ? 0 : (progPerLayer * mult);
+ } else {
+ const bonus = perfect ? Math.min(4, comboBefore) : 0;
+ pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
+ }
+ const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0;
+ return {
+ miss,
+ perfect,
+ pts,
+ progressDelta,
+ landOffsetTiles: raw,
+ placedW: fallW,
+ placedCx,
+ overlap,
+ supportRatio,
+ delta,
+ };
+ }
+
+ /**
+ * เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
+ * @param {object} hit จาก computeStackDropResult
+ * @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
+ * @returns {boolean}
+ */
+ function startStackFallAnimation(hit, previewActor) {
+ if (!stackMini || !hit || stackFall || stackMini.settling) return false;
+ ensureStackNextDropVisual();
+ const m = stackMini;
+ const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat();
+ const actorHeavy = !!m.pendingDropHeavy;
+ const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay();
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh);
+ const swingAttachY = m.swingWorldY - swingLift;
+ let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
+ if (y1 <= swingAttachY) y1 = swingAttachY + lh * 0.55;
+ const landOff = hit.landOffsetTiles || 0;
+ const landCxWorld = (m.topCenterX + landOff) * tileSize;
+ const xLeft0 = swingXW - tw / 2;
+ const xLeft1 = landCxWorld - tw / 2;
+ const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
+ const dur = Math.max(400, Math.min(1280, 240 + (y1 - swingAttachY) * 0.92 + offN * 560));
+ const tiltMax = hit.miss
+ ? 0.28 * (0.25 + offN)
+ : 0.16 * offN * (hit.perfect ? 0.1 : 1);
+ const isHumanPreview = !!(previewActor && previewActor.human);
+ const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
+ const cableTopYRel = isStackTowerMissionUiMapPlay()
+ ? (swingAttachY - tileSize * 6)
+ : Math.max(0, swingAttachY - tileSize * 6);
+ const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ const releaseRopeAng = Math.atan2(swingAttachY - cableTopYRel, swingXW - craneXRel);
+ stackFall = {
+ t0: performance.now(),
+ dur,
+ xLeft0,
+ xLeft1,
+ tw,
+ y0: swingAttachY,
+ y1,
+ hit,
+ tiltMax,
+ sloppyN: offN,
+ previewHumanDrop: isHumanPreview,
+ previewBotId: botIdPreview,
+ releaseRopeAng,
+ releaseAttachWorldX: swingXW,
+ releaseAttachWorldY: swingAttachY,
+ actorSeat,
+ actorHeavy,
+ };
+ return true;
+ }
+
+ function stackHumanDropGateOkPlay() {
+ if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false;
+ if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
+ if (Date.now() - stackMini.lastDropAt < 480) return false;
+ return true;
+ }
+
+ /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
+ function tryHumanStackDrop() {
+ if (!stackHumanDropGateOkPlay()) return false;
+ ensureStackNextDropVisual();
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
+ if (playBotsEnabled()) {
+ lastStackPreviewActorId = '__human__';
+ lastStackPreviewActorUntil = Date.now() + 900;
+ }
+ return true;
+ }
+
+ /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
+ function simulatePreviewBotStackDrop(botId, o) {
+ if (!stackMini || stackFall || stackMini.settling) return;
+ ensureStackNextDropVisual();
+ previewBotAlignStackSwingPhasePlay(stackMini, o);
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
+ o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ lastStackPreviewActorId = botId;
+ lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ }
+
+ /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
+ function stepPreviewStackTurnBased(nowMs) {
+ if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return;
+ if (mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ const order = stackPreviewTurnOrder;
+ if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
+ const cur = order[stackPreviewTurnIndex];
+ if (!cur || cur.kind !== 'bot') return;
+ if (!cur.botId) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ const o = others.get(cur.botId);
+ if (!o) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ if (stackPreviewBotThinkUntil === 0) {
+ const tier = o.botTier === 'sharp' ? 'sharp' : o.botTier === 'weak' ? 'weak' : 'avg';
+ const thinkLo = tier === 'sharp' ? 160 : tier === 'avg' ? 260 : 340;
+ const thinkHi = tier === 'sharp' ? 340 : tier === 'avg' ? 520 : 620;
+ stackPreviewBotThinkUntil = nowMs + thinkLo + Math.random() * (thinkHi - thinkLo);
+ return;
+ }
+ if (nowMs < stackPreviewBotThinkUntil) return;
+ stackPreviewBotThinkUntil = 0;
+ simulatePreviewBotStackDrop(cur.botId, o);
+ }
+
+ function stackTickFrame() {
+ stackTowerMissionMaybeEndPlay();
+ const now = performance.now();
+ const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
+ lastStackTickMs = now;
+ if (stackMini && stackMini.settling) {
+ const s = stackMini.settling;
+ const u = (now - s.t0) / s.dur;
+ if (u >= 1) {
+ const wasHuman = !!s.previewHumanDrop;
+ const settleBotId = s.previewBotId || null;
+ applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
+ if (playBotsEnabled() && (wasHuman || settleBotId)) {
+ stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
+ }
+ stackMini.settling = null;
+ stackMini.lastDropAt = Date.now();
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn();
+ }
+ } else if (stackFall) {
+ const t = (now - stackFall.t0) / stackFall.dur;
+ if (t >= 1) {
+ const wasHuman = !!stackFall.previewHumanDrop;
+ const fallBotId = stackFall.previewBotId || null;
+ const hit = stackFall.hit;
+ const stableMin = STACK_STABLE_SUPPORT_RATIO_MIN;
+ if (hit.miss) {
+ spawnStackMissDebrisPlay(stackFall);
+ applyStackHitFromDrop(hit);
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ } else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
+ const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
+ stackMini.settling = {
+ t0: performance.now(),
+ dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
+ hit,
+ xLeft0: stackFall.xLeft1,
+ yTop0: stackFall.y1,
+ twWorld: stackFall.tw,
+ stripH,
+ delta: hit.delta || 0,
+ previewHumanDrop: wasHuman,
+ previewBotId: fallBotId,
+ actorSeat: stackFall.actorSeat,
+ actorHeavy: stackFall.actorHeavy,
+ };
+ stackFall = null;
+ } else {
+ let previewAttr;
+ if (playBotsEnabled()) {
+ if (wasHuman) previewAttr = { human: true };
+ else if (fallBotId) previewAttr = { botId: fallBotId };
+ }
+ applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy });
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ }
+ }
+ } else if (stackMini) {
+ stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
+ }
+ stepPreviewStackTurnBased(Date.now());
+ }
+
+ /**
+ * จุดบนจอที่เส้นก้าน→บล็อกตัดแนว y = -(ความสูงแคนวาส × STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC)
+ * — ปลายเชือกอยู่เหนือขอบบน ~25% ของความสูงจอ (มองเห็นช่วงที่เข้ามาในจอ)
+ */
+ function stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, canvasH) {
+ const ch = Math.max(80, Number(canvasH) || 1080);
+ const marginTop = -ch * STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC;
+ const ddx = tcx - slingBx;
+ const ddy = tcy - slingBy;
+ if (Math.abs(ddy) < 1e-4) {
+ return { x: tcx, y: Math.min(marginTop, Math.min(tcy, slingBy)) };
+ }
+ const vTop = (marginTop - slingBy) / ddy;
+ if (!Number.isFinite(vTop) || vTop < 0) {
+ return { x: tcx, y: tcy };
+ }
+ const xTop = slingBx + vTop * ddx;
+ return { x: xTop, y: marginTop };
+ }
+
+ function applyStackPreviewSpawnLayout() {
+ if (!mapData || mapData.gameType !== 'stack') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const cx = land ? land.cx : w / 2 - 0.5;
+ const cy = land ? land.cy : h / 2 - 0.5;
+ me.x = cx;
+ me.y = cy;
+ me.tx = cx;
+ me.ty = cy;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.x = cx + (Math.random() - 0.5) * 0.15;
+ o.y = cy + (Math.random() - 0.5) * 0.15;
+ o.tx = o.x;
+ o.ty = o.y;
+ o.stackBotScore = 0;
+ o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
+ });
+ }
+
+ function drawStackMinigame(ctx, worldToScreen, zoom) {
+ if (!stackMini) return;
+ const m = stackMini;
+ const towerHudLive = isStackTowerMissionHudActivePlay();
+ const wMul = getStackTowerBlockWorldScalePlay();
+ ensureStackNextDropVisual();
+ const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
+ const floorY = m.floorWorldY;
+ const swingLiftDraw = getStackTowerSwingLiftWorldPx(m.layers.length, layerWorldH);
+ const swingDrawY = m.swingWorldY - swingLiftDraw;
+ const hidLayers = towerHudLive ? getStackTowerVisualHiddenLayerCountPlay() : 0;
+
+ const nLay = m.layers.length;
+ for (let i = hidLayers; i < nLay; i++) {
+ const L = m.layers[i];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (i + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const hue = (i * 47) % 360;
+ const seatL = L.seat != null ? L.seat : 1;
+ const heavyL = !!L.heavy;
+ if (towerHudLive && L.perfectGlowUntil != null) {
+ const ntGlow = performance.now();
+ if (ntGlow < L.perfectGlowUntil) {
+ drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil);
+ }
+ }
+ drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {});
+ }
+ if (towerHudLive && m.perfectStackX2Fx) {
+ const fxX2 = m.perfectStackX2Fx;
+ const ntX = performance.now();
+ if (ntX >= fxX2.until) {
+ m.perfectStackX2Fx = null;
+ } else {
+ const ti = fxX2.topLayerIndex;
+ if (ti >= hidLayers && ti >= 0 && ti < m.layers.length) {
+ const Ltop = m.layers[ti];
+ const lwT = (Ltop.w != null ? Ltop.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxWT = Ltop.cx * tileSize;
+ const ybT = floorY - (ti + 1) * layerWorldH;
+ const xlT = cxWT - lwT / 2;
+ const [sxT, syT] = worldToScreen(xlT, ybT);
+ const drawWT = lwT * zoom;
+ const drawHT = layerWorldH * zoom;
+ const fxImX = ensureStackTowerPerfectFxImagesPlay();
+ if (fxImX.x2) {
+ drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until);
+ }
+ }
+ }
+ }
+ if (towerHudLive) {
+ drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY);
+ }
+ if (m.settling) {
+ const s = m.settling;
+ const u = Math.min(1, (performance.now() - s.t0) / s.dur);
+ const ue = u * u;
+ const rotSign = s.delta >= 0 ? 1 : -1;
+ const rotRad = ue * 1.22 * rotSign;
+ const yFall = ue * ue * tileSize * 6.5;
+ const xSlide = rotSign * ue * tileSize * 0.62;
+ const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
+ const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
+ const [cxs, cys] = worldToScreen(cxW, cyW);
+ const drawW = s.twWorld * zoom;
+ const drawH = s.stripH * zoom;
+ const hue = (m.layers.length * 47) % 360;
+ const seatS = s.actorSeat != null ? s.actorSeat : 1;
+ const heavyS = !!s.actorHeavy;
+ ctx.save();
+ ctx.translate(cxs, cys);
+ ctx.rotate(rotRad);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {});
+ ctx.restore();
+ } else {
+ const twWorld = m.widthTiles * tileSize * wMul;
+ const drawStripPx = stripH * zoom;
+ const cableTopY = isStackTowerMissionUiMapPlay()
+ ? (swingDrawY - tileSize * 6)
+ : Math.max(0, swingDrawY - tileSize * 6);
+ const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ let blockLeftWorld;
+ let blockTopWorld;
+ let fallTiltRad = 0;
+ if (stackFall) {
+ const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
+ const t = Math.min(1, tRaw);
+ const u = stackEaseDropPhys(t, stackFall.sloppyN);
+ blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
+ blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
+ fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
+ } else {
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ blockLeftWorld = swingXW - twWorld / 2;
+ blockTopWorld = swingDrawY;
+ }
+ const cxMid = blockLeftWorld + twWorld / 2;
+ const [tcx, tcy] = worldToScreen(craneX, cableTopY);
+ const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
+ let slingBx = bcx;
+ let slingBy = bcy;
+ if (stackFall && stackFall.releaseAttachWorldX != null) {
+ const attY = stackFall.releaseAttachWorldY;
+ const p = worldToScreen(stackFall.releaseAttachWorldX, attY);
+ slingBx = p[0];
+ slingBy = p[1];
+ }
+ let ropeSx0 = tcx;
+ let ropeSy0 = tcy;
+ let ropeSx1 = slingBx;
+ let ropeSy1 = slingBy;
+ if (isStackTowerMissionUiMapPlay()) {
+ const chCv = ctx.canvas && ctx.canvas.height ? ctx.canvas.height : STACK_TOWER_FIXED_RENDER_H;
+ const topA = stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, chCv);
+ ropeSx0 = topA.x;
+ ropeSy0 = topA.y;
+ ropeSx1 = slingBx;
+ ropeSy1 = slingBy;
+ const t1 = Math.max(0, Math.min(1, STACK_TOWER_ROPE_DRAW_T1));
+ if (t1 < 1) {
+ ropeSx1 = ropeSx0 + (slingBx - ropeSx0) * t1;
+ ropeSy1 = ropeSy0 + (slingBy - ropeSy0) * t1;
+ }
+ }
+ drawStackSlingSegmentPlay(ctx, ropeSx0, ropeSy0, ropeSx1, ropeSy1, zoom);
+ const ropeAng = stackFall && stackFall.releaseRopeAng != null
+ ? stackFall.releaseRopeAng
+ : Math.atan2(bcy - ropeSy0, bcx - ropeSx0);
+ const swingTiltRad = ropeAng - Math.PI / 2;
+ const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
+ const seatDraw = stackFall && stackFall.actorSeat != null
+ ? stackFall.actorSeat
+ : (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat());
+ const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy;
+ const hueHint = (m.layers.length * 47) % 360;
+ ctx.save();
+ ctx.translate(bcx, bcy);
+ ctx.rotate(fallTiltRad + swingTiltRad);
+ drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint });
+ ctx.lineWidth = 1;
+ ctx.restore();
+ }
+ if (towerHudLive) {
+ drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay);
+ }
+ drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorY);
+ }
+
+ function applyGauntletTimingFromServer(payload) {
+ if (!payload || typeof payload !== 'object') return;
+ const tm = Number(payload.gauntletTickMs);
+ if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
+ const jt = Number(payload.gauntletJumpTicks);
+ if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
+ const tl = Number(payload.gauntletTimeLimitSec);
+ if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
+ if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
+ else {
+ const ge = Number(payload.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) {
+ gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
+ if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
+ gauntletLaneImageUrls = payload.gauntletLaneImageUrls
+ .filter((x) => typeof x === 'string')
+ .map((x) => normalizeGauntletAssetUrlForPlay(x))
+ .filter((x) => x)
+ .slice(0, 24);
+ gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
+ gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '');
+ ensureGauntletAssetImage(gauntletLaserTopUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
+ gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '');
+ ensureGauntletAssetImage(gauntletLaserBottomUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
+ gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '');
+ ensureGauntletAssetImage(gauntletLaserLineUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
+ gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
+ gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
+ const lw = Number(payload.gauntletLaserLineWidthPx);
+ if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
+ const sh = Number(payload.stackSwingHz);
+ if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
+ const raw = payload.stackBlockWidthTiles;
+ if (raw == null || raw === '') playStackBlockWidthTiles = null;
+ else {
+ const nw = Number(raw);
+ playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
+ ? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
+ : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerMissionTimeSec')) {
+ const st = Number(payload.stackTowerMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playStackTowerMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playStackTowerMissionTimeSec = 90;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTeamMissesMax')) {
+ const sm = Number(payload.stackTeamMissesMax);
+ if (Number.isFinite(sm)) {
+ playStackTeamMissesMax = Math.max(1, Math.min(20, Math.floor(sm)));
+ } else {
+ playStackTeamMissesMax = 3;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerProgressBlocks')) {
+ const pb = Number(payload.stackTowerProgressBlocks);
+ if (Number.isFinite(pb) && pb >= 1) {
+ playStackTowerProgressBlocks = Math.max(1, Math.min(500, Math.floor(pb)));
+ } else {
+ playStackTowerProgressBlocks = 50;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) {
+ playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) {
+ playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) {
+ const hp = Number(payload.stackHeavyBlockPercent);
+ if (Number.isFinite(hp)) {
+ playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp)));
+ } else {
+ playStackHeavyBlockPercent = 35;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
+ const jm = Number(payload.jumpSurviveJumpHeightMult);
+ if (Number.isFinite(jm)) {
+ playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) {
+ const jt = Number(payload.jumpSurviveMissionTimeSec);
+ if (Number.isFinite(jt) && jt > 0) {
+ playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt)));
+ } else {
+ playJumpSurviveMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTiles')
+ || Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')) {
+ const legacy = Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')
+ ? normalizeGauntletAssetUrlForPlay(typeof payload.jumpSurvivePlatformTileUrl === 'string' ? payload.jumpSurvivePlatformTileUrl : '')
+ : '';
+ const arr = Array.isArray(payload.jumpSurvivePlatformTiles) ? payload.jumpSurvivePlatformTiles : [];
+ const next = [];
+ for (let pi = 0; pi < 3; pi++) {
+ const o = arr[pi] && typeof arr[pi] === 'object' ? arr[pi] : {};
+ let url = normalizeGauntletAssetUrlForPlay(typeof o.url === 'string' ? o.url : '');
+ if (pi === 0 && !url && legacy) url = legacy;
+ let ww = Number(o.w);
+ let hh = Number(o.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ next.push({ url, w: ww, h: hh });
+ if (url) ensureGauntletAssetImage(url);
+ }
+ playJumpSurvivePlatformTiles = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) {
+ const st = Number(payload.spaceShooterMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playSpaceShooterMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipImageUrls')) {
+ const a = payload.spaceShooterShipImageUrls;
+ const next = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ next[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipImageUrls = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidSpriteUrls')) {
+ const raw = payload.spaceShooterAsteroidSpriteUrls;
+ const next = [];
+ if (Array.isArray(raw)) {
+ for (let ai = 0; ai < raw.length && next.length < 32; ai++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof raw[ai] === 'string' ? raw[ai] : '');
+ if (u) next.push(u);
+ }
+ }
+ playSpaceShooterAsteroidSpriteUrls = next;
+ next.forEach((u) => {
+ if (u) ensureGauntletAssetImage(u);
+ });
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidExplodeFrameMs')) {
+ const ef = Number(payload.spaceShooterAsteroidExplodeFrameMs);
+ if (Number.isFinite(ef)) {
+ playSpaceShooterAsteroidExplodeFrameMs = Math.max(30, Math.min(500, Math.round(ef)));
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidIntervalMs')) {
+ const iv = Number(payload.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(iv) && iv >= 200) {
+ playSpaceShooterAsteroidIntervalMs = Math.min(10000, Math.floor(iv));
+ } else {
+ playSpaceShooterAsteroidIntervalMs = 1040;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipDamageOverlayUrls')) {
+ const rawD = payload.spaceShooterShipDamageOverlayUrls;
+ const nextD = ['', '', ''];
+ if (Array.isArray(rawD)) {
+ for (let di = 0; di < 3; di++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof rawD[di] === 'string' ? rawD[di] : '');
+ nextD[di] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipDamageOverlayUrls = nextD;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) {
+ const st = Number(payload.balloonBossMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playBalloonBossMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) {
+ playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : ''
+ );
+ if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
+ const a = payload.balloonBossPlayerBalloonImageUrls;
+ const nextBb = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ nextBb[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playBalloonBossPlayerBalloonImageUrls = nextBb;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) {
+ playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : ''
+ );
+ if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl);
+ }
+ if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
+ }
+
+ /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
+ const GAUNTLET_VIS_LERP = 0.42;
+
+ function gauntletObsSmoothstep(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t * (3 - 2 * t);
+ }
+
+ function cloneGauntletObsSnap(arr) {
+ if (!Array.isArray(arr)) return [];
+ return arr.map((o) => {
+ if (!o || o.id == null) return null;
+ const row = { id: o.id, kind: o.kind, x: o.x, y: o.y };
+ if (o.kind === 'laser') {
+ if (o.y0 != null) row.y0 = o.y0;
+ if (o.y1 != null) row.y1 = o.y1;
+ }
+ return row;
+ }).filter(Boolean);
+ }
+
+ function gauntletLaserRowHitRange(ob, mapH) {
+ const h = Math.max(1, Math.floor(Number(mapH)) || 15);
+ let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0;
+ let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1;
+ y0 = Math.max(0, Math.min(h - 1, y0));
+ y1 = Math.max(0, Math.min(h - 1, y1));
+ if (y1 < y0) {
+ const t = y0;
+ y0 = y1;
+ y1 = t;
+ }
+ return { y0, y1 };
+ }
+
+ function gauntletLaserOverlapsPlayerRow(ob, py, mapH) {
+ const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH);
+ return py >= y0 && py <= y1;
+ }
+
+ /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
+ function gauntletLiftHeightNorm(air, jumpTicksMax) {
+ const jm = Math.max(4, jumpTicksMax || 16);
+ const a = Math.max(0, Math.min(jm, Number(air) || 0));
+ const t = a / jm;
+ /* อาร์คกระโดดนุ่มแบบ design (rn-jump): sine โค้งสมมาตร ease-in-out peak กลางอากาศ
+ เดิม: ขาขึ้น quadratic / ขาลง linear (อสมมาตร peak ~72% เวลา → ขาลงกระตุก) */
+ const p = Math.max(0, Math.min(1, 1 - t)); /* 0 = เริ่มกระโดด, 1 = แตะพื้น */
+ return Math.sin(Math.PI * p);
+ }
+
+ /* ===== Minigame-2: ชื่อ+คะแนนใต้ตัวละคร + เลข -10 เป็น DOM overlay (sync world→screen) แทนวาดบน canvas =====
+ เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */
+ const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */
+ const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */
+ function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) {
+ const ov = document.getElementById('gauntlet-name-overlay');
+ if (!ov) return;
+ const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
+ if (!live || typeof worldToScreenFn !== 'function') {
+ if (!ov.classList.contains('is-hidden')) {
+ ov.classList.add('is-hidden');
+ gauntletDomLabelPool.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); });
+ gauntletDomLabelPool.clear();
+ }
+ return;
+ }
+ ov.classList.remove('is-hidden');
+ const canvas = document.getElementById('game-canvas');
+ const stage = document.getElementById('play-canvas-stage');
+ if (!canvas || !stage) return;
+ const cr = canvas.getBoundingClientRect();
+ const srect = stage.getBoundingClientRect();
+ const dispScale = canvas.width > 0 ? cr.width / canvas.width : 1;
+ ov.style.left = Math.round(cr.left - srect.left) + 'px';
+ ov.style.top = Math.round(cr.top - srect.top) + 'px';
+ ov.style.width = canvas.width + 'px';
+ ov.style.height = canvas.height + 'px';
+ ov.style.transform = 'scale(' + dispScale + ')';
+ const fp = (typeof getCharacterFootprintWH === 'function') ? getCharacterFootprintWH(mapData) : { cw: 1, ch: 1 };
+ const cwv = fp.cw || 1, chv = fp.ch || 1;
+ function v(n) { const x = Number(n); return Number.isFinite(x) ? x : 0; }
+ const seen = new Set();
+ function place(id, ent, isMe) {
+ if (!ent) return;
+ if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'
+ && shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return;
+ seen.add(id);
+ const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0);
+ const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * 0.52 : 0;
+ const cxWorld = (v(ent.x) + cwv * 0.5) * tileSizePx;
+ const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY;
+ const scr = worldToScreenFn(cxWorld, cyBottomWorld);
+ const sx = scr[0], sy = scr[1];
+ /* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร
+ (กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */
+ const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0;
+ /* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */
+ var sbEl = document.querySelector('.play-cyber-scoreboard');
+ var sbBottomBuf = sbEl ? ((sbEl.getBoundingClientRect().bottom - cr.top) / (dispScale || 1) + 6) : 0;
+ if (pen > (gauntletPenaltySeenById.get(id) || 0) && (sy - 28) > sbBottomBuf) {
+ gauntletPenaltySeenById.set(id, pen);
+ /* -10 float = design asset score-.png (รูป "-10" สำเร็จรูป) ไม่ใช่ text สไตล์เอง */
+ const pf = document.createElement('img');
+ pf.className = 'gnt-pen';
+ pf.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ pf.alt = '-10';
+ pf.decoding = 'async';
+ pf.style.left = Math.round(sx) + 'px';
+ pf.style.top = Math.round(sy - 28) + 'px';
+ ov.appendChild(pf);
+ setTimeout(function () { if (pf.parentNode) pf.parentNode.removeChild(pf); }, 900);
+ } else if (pen > (gauntletPenaltySeenById.get(id) || 0)) {
+ /* อยู่บริเวณแถบคะแนน — บันทึกว่าเห็นแล้ว ไม่ต้องสร้าง float (กัน spawn ซ้ำตอนเลื่อนลงมา) */
+ gauntletPenaltySeenById.set(id, pen);
+ }
+ }
+ if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false));
+ place('__me', me, true);
+ }
+
+ function getGauntletObsDrawPositionsAt(nowMs) {
+ if (!gauntletObsRenderNext.length) return [];
+ const elapsed = nowMs - gauntletObsBlendT0;
+ const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs);
+ const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o]));
+ const out = [];
+ for (let i = 0; i < gauntletObsRenderNext.length; i++) {
+ const n = gauntletObsRenderNext[i];
+ if (!n) continue;
+ const p = prevMap.get(n.id);
+ let drawX = n.x;
+ if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha;
+ const row = { id: n.id, kind: n.kind, drawX, y: n.y };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ out.push(row);
+ }
+ return out;
+ }
+
+ function pushGauntletObsRenderFrame(newObs) {
+ const now = performance.now();
+ const next = cloneGauntletObsSnap(newObs);
+ if (!gauntletObsRenderNext.length) {
+ gauntletObsRenderPrev = next.slice();
+ gauntletObsRenderNext = next.slice();
+ } else {
+ const curDraw = getGauntletObsDrawPositionsAt(now);
+ const curMap = new Map(curDraw.map((o) => [o.id, o.drawX]));
+ gauntletObsRenderPrev = next.map((n) => {
+ const row = {
+ id: n.id,
+ kind: n.kind,
+ x: curMap.has(n.id) ? curMap.get(n.id) : n.x,
+ y: n.y,
+ };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ return row;
+ });
+ gauntletObsRenderNext = next;
+ }
+ gauntletObsBlendT0 = now;
+ }
+
+ function lerpGauntletEntityPos(x, y, tx, ty) {
+ let nx = x;
+ let ny = y;
+ if (tx != null && Number.isFinite(tx)) {
+ nx += (tx - nx) * GAUNTLET_VIS_LERP;
+ if (Math.abs(tx - nx) < 0.02) nx = tx;
+ }
+ if (ty != null && Number.isFinite(ty)) {
+ ny += (ty - ny) * GAUNTLET_VIS_LERP;
+ if (Math.abs(ty - ny) < 0.02) ny = ty;
+ }
+ return { nx, ny };
+ }
+
+ /**
+ * บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท)
+ * — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป
+ */
+ function maybePreviewBotGauntletJump(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ if ((o.gauntletJumpTicks || 0) > 0) return;
+ const { ch: gauntletCh } = getCharacterFootprintWH(mapData);
+ let sameCell = false;
+ let incomingCol = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const obs = obstacles[i];
+ if (!obs) continue;
+ if (obs.kind === 'lane' && typeof obs.y === 'number') {
+ if (obs.x === px && obs.y >= py && obs.y < py + gauntletCh) sameCell = true;
+ if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true;
+ }
+ if (obs.kind === 'laser' && typeof obs.x === 'number') {
+ if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true;
+ if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) incomingCol = true;
+ }
+ }
+ const tier = o.botTier || 'avg';
+ const roll = Math.random();
+ const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86;
+ const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72;
+ if (sameCell && roll < pSame) {
+ o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ return;
+ }
+ if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ }
+
+ /** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */
+ function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ const air = (o.gauntletJumpTicks || 0) > 0;
+ const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData);
+ const crown = isGauntletCrownHeistMapPlay();
+ let advanceX = false;
+ let hitBack = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const ob = obstacles[i];
+ if (!ob) continue;
+ if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y >= py && ob.y < py + gauntletCh2) {
+ if (air) advanceX = true;
+ else hitBack = true;
+ }
+ if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) {
+ if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) {
+ /* เลเซอร์ไม่ครอบแถวนี้ */
+ } else if (air) {
+ advanceX = true;
+ } else {
+ hitBack = true;
+ }
+ }
+ }
+ if (advanceX) {
+ px = Math.min(w - 2, px + 1);
+ o.gauntletJumpTicks = 0;
+ if (!crown) {
+ o.gauntletScore = (o.gauntletScore || 0) + 1;
+ }
+ } else if (hitBack) {
+ if (crown) {
+ if (px <= 0) {
+ o.gauntletEliminated = true;
+ o.gauntletScore = 0;
+ } else {
+ o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10);
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ px = Math.max(0, px - 1);
+ }
+ } else {
+ px = Math.max(0, px - 1);
+ }
+ }
+ if (crown && mapData) {
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx != null && Number.isFinite(mx)) {
+ const maxPx = Math.floor(mx + 1e-6);
+ px = Math.min(px, maxPx);
+ }
+ }
+ if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
+ o.tx = px;
+ o.ty = py;
+ }
+
+ function applyGauntletPreviewBotsAfterSync() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) return;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o) return;
+ maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
+ applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
+ });
+ }
+
+ /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
+ function emitGauntletPreviewRowsToServer() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const h = mapData.height || 15;
+ const ysSet = new Set();
+ const addY = (v) => {
+ const fy = Math.floor(Number(v));
+ if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
+ };
+ addY(me.y);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) addY(o.y);
+ });
+ socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
+ }
+
+ /** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */
+ function hideConflictingOverlaysForGauntletCrown() {
+ hideStackTowerResultFlashDomOnlyPlay();
+ hideQuizCarryPregameOverlay();
+ quizCarryPregameActive = false;
+ const hideIds = [
+ 'quiz-game-overlay',
+ 'quiz-carry-mission-overlay',
+ 'gauntlet-ended-overlay',
+ 'quiz-battle-mcq-overlay',
+ 'quiz-carry-embed-countdown',
+ 'gauntlet-crown-countdown',
+ 'quiz-carry-embed-q-strip',
+ 'play-quiz-scoreboard',
+ 'play-quiz-feedback',
+ ];
+ for (let i = 0; i < hideIds.length; i++) {
+ const el = document.getElementById(hideIds[i]);
+ if (el) el.classList.add('is-hidden');
+ }
+ const gcm = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcm) gcm.classList.add('is-hidden');
+ hideStackTowerHowtoRulesDom();
+ const gchHowto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (gchHowto) gchHowto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+
+ function gcmRankBadgeUrl(rank) {
+ const r = Math.floor(Number(rank)) || 99;
+ if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png';
+ if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png';
+ if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png';
+ return BASE + '/img/gauntlet-assets/result-txt-2.png';
+ }
+
+ /** สรุปภารกิจแบบ mockup — ต่อมินิเกม mission map (Minigame 1/4–7 + Last Light mno9kb07) */
+ function isGcmMissionMockSummaryPlay(mission) {
+ if (!mission || !mission.uiSkin) {
+ return isGauntletCrownHeistMapPlay();
+ }
+ if (mission.uiSkin === 'gauntlet_crown') return isGauntletCrownHeistMapPlay();
+ if (mission.uiSkin === 'question_mission') return isQuizQuestionMissionUiMapPlay();
+ if (mission.uiSkin === 'jumper') return isJumpSurviveMissionUiMapPlay();
+ if (mission.uiSkin === 'violent_crime') return isSpaceShooterMissionUiMapPlay();
+ if (mission.uiSkin === 'mega_virus') return isMegaVirusMissionShellMapPlay();
+ if (mission.uiSkin === 'stack_tower') return isStackTowerMissionUiMapPlay();
+ if (mission.uiSkin === 'quiz_carry') return quizCarryEmbedMissionFlowActive() || isQuizCarry();
+ return false;
+ }
+
+ function gcmMissionMockAssetUrl(mission, file) {
+ if (!mission) {
+ return isGauntletCrownHeistMapPlay()
+ ? (BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, ''))
+ : questionMissionAssetUrl(file);
+ }
+ const skin = mission.uiSkin;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(file);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(file);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(file);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(file);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + String(file || '').replace(/^\//, '');
+ return questionMissionAssetUrl(file);
+ }
+
+ function resolveGauntletCrownMissionUiSkinPlay(mission) {
+ if (mission && mission.uiSkin) return mission.uiSkin;
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return mission && mission.uiSkin;
+ }
+
+ function setGauntletCrownMg2CountdownGraphic(numEl, textEl, n) {
+ const d = Math.max(1, Math.min(3, n));
+ if (textEl) {
+ textEl.textContent = String(d);
+ textEl.setAttribute('aria-label', String(d));
+ }
+ if (numEl) {
+ numEl.removeAttribute('src');
+ numEl.alt = '';
+ }
+ }
+
+ function syncGauntletCrownCountdownMg2ModePlay(cd) {
+ if (!cd) return;
+ const mg2 = isGauntletCrownHeistMapPlay();
+ cd.classList.toggle('gcc-mg2-mock', mg2);
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ if (numEl) {
+ numEl.classList.toggle('gcc-num--img', !mg2);
+ numEl.classList.toggle('is-hidden', !!mg2);
+ numEl.setAttribute('aria-hidden', mg2 ? 'true' : 'false');
+ }
+ if (textEl) {
+ textEl.classList.toggle('is-hidden', !mg2);
+ textEl.setAttribute('aria-hidden', mg2 ? 'false' : 'true');
+ }
+ }
+
+ function gauntletCrownPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (gauntletCrownLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function gauntletCrownSyncGuestReadyIfNeeded() {
+ const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto';
+ const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto';
+ const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto';
+ const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto';
+ if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return;
+ if (myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (gauntletCrownLobbyReadyMap[sid]) return;
+ gauntletCrownLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true });
+ }
+
+ /** Minigame-2 mno9kb07 — HOW TO PLAY ใช้ layout mock 01-howto (canvas = mg-bg) */
+ function gauntletCrownHowtoUsesMg2LayoutPlay() {
+ return !!isGauntletCrownHeistMapPlay();
+ }
+ function getGauntletCrownHowtoStatusEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('gauntlet-crown-howto-status-mg2');
+ }
+ return document.getElementById('gauntlet-crown-howto-status');
+ }
+ function getGauntletCrownHowtoBtnEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('btn-gch-ready-mg2');
+ }
+ return document.getElementById('btn-gch-ready');
+ }
+ function setPlayMg2HowtoLivePlay(on) {
+ document.documentElement.classList.toggle('play-mg2-howto-live', !!on);
+ }
+ function clearGchMg2OverlayBoundsPlay(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ ['position', 'left', 'top', 'width', 'height', 'right', 'bottom', 'transform'].forEach((p) => {
+ ov.style.removeProperty(p);
+ });
+ }
+ function dismissGauntletCrownHowtoOverlayPlay() {
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ setPlayMg2HowtoLivePlay(false);
+ clearGchMg2OverlayBoundsPlay(howto);
+ }
+ gauntletCrownHowtoVisible = false;
+ }
+
+ /** ภารกิจคำถาม mng8a80o — Ready Status / ปุ่ม แบบเดียวกับ Mega Virus (gauntlet-crown-lobby-sync) */
+ function updateQuizQuestionMissionHowtoHud() {
+ if (!isQuizQuestionMissionUiMapPlay() || quizQuestionMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function updateGauntletCrownHowtoHud() {
+ const st = getGauntletCrownHowtoStatusEl();
+ const btn = getGauntletCrownHowtoBtnEl();
+ if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ if (st) {
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ }
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ const btnImg = document.getElementById('btn-gch-ready-mg2-img');
+ if (btnImg) {
+ btnImg.src = humansReady
+ ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png'))
+ : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png'));
+ btnImg.alt = humansReady ? 'START' : 'READY';
+ }
+ }
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginGauntletCrownCountdownThenRun() {
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'countdown';
+ if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ }
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ dismissGauntletCrownHowtoOverlayPlay();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ function applyMegaBalloonLiveStartPlay() {
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastTickMs = performance.now();
+ balloonBossGameEnded = false;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossBossFireAcc = 0;
+ balloonBossScorePopups = [];
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (mapData) applyBalloonBossSpawnLayoutPlay();
+ }
+ const runFinish = () => {
+ if (cd) cd.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ if (socket && socket.connected) {
+ socket.emit('gauntlet-crown-begin-run', (res) => {
+ if (res && res.ok) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ });
+ } else {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ const mg2Cd = isGauntletCrownHeistMapPlay();
+ cd.classList.remove('is-hidden');
+ let n = 3;
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = () => {
+ n--;
+ if (n > 0) {
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ } else {
+ gauntletCrownCountdownTimer = null;
+ runFinish();
+ }
+ };
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ function showGauntletCrownHowtoOverlay() {
+ if (!usesCrownLobbyShellPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ if (isGauntletCrownHeistMapPlay()) applyGauntletCrownHeistMissionPanelImages();
+ else if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) {
+ cd.classList.add('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ }
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'howto';
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ if (isGauntletCrownHeistMapPlay()) {
+ setPlayMg2HowtoLivePlay(true);
+ updateGchTfMockScale(ov);
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */
+ function gauntletCrownEmbedMissionAnyOk(mission) {
+ if (!mission) return false;
+ if (mission.uiSkin === 'violent_crime') {
+ const sc = Number(mission.survivorCount);
+ if (Number.isFinite(sc) && sc <= 0) return false;
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'jumper') {
+ const sc = Number(mission.survivorCount);
+ return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F');
+ }
+ if (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'mega_virus') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+
+ function gauntletCrownRankBonusLocal(rankOrdinal) {
+ if (rankOrdinal === 1) return 100;
+ if (rankOrdinal === 2) return 80;
+ if (rankOrdinal === 3) return 60;
+ return 0;
+ }
+
+ function gauntletCrownRollRewardCardLocal(grade) {
+ const r = Math.random();
+ if (grade === 'A') {
+ if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ /** พรีวิว: สรุปภารกิจ Last Light หลังรันเวย์ครบ — ให้ gauntletCrownBuildDisplayMission รวมบอท */
+ function gauntletCrownHeistBuildLocalCrownMissionPlay() {
+ const baseRows = [];
+ if (myId != null) {
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o || isPreviewBotId(id)) return;
+ baseRows.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const bs = Math.max(0, Number(row.baseScore) | 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus,
+ finalScore: bs + rankBonus,
+ };
+ });
+ const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = baseRows.filter((r) => !r.eliminated).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ uiSkin: 'gauntlet_crown',
+ ranked,
+ totalSum,
+ averageScore,
+ grade,
+ rewardCard,
+ totalParts: ranked.map((r) => r.finalScore),
+ participantCount: baseRows.length,
+ survivorCount,
+ };
+ }
+
+ /**
+ * Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์
+ * — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row)
+ */
+ function gauntletCrownBuildDisplayMission(mission) {
+ if (!mission || !Array.isArray(mission.ranked)) return mission;
+ if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission;
+ if (!(playBotsEnabled() && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission;
+
+ const seen = new Set();
+ const baseRows = [];
+ mission.ranked.forEach((r) => {
+ if (!r) return;
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: r.id,
+ nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น',
+ characterId: r.characterId ?? null,
+ baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ });
+ });
+ others.forEach((o, id) => {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ /* รวมตัวเรา (human) ถ้ายังไม่อยู่ — บาง mission (preview/จาก server) มีแต่บอท ทำให้ตัวเราตกหายจาก summary */
+ if (myId != null && !seen.has(String(myId))) {
+ seen.add(String(myId));
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 รวมตัวเรา (เดิม 5 ตัดคนคะแนนต่ำสุด/ตายออก) */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos);
+ const finalScore = row.baseScore + rankBonus;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: rankLabel,
+ rankBonus: rankBonus,
+ finalScore: finalScore,
+ };
+ });
+
+ const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ const rewardCard = gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: ranked.map(function (r) { return r.finalScore; }),
+ };
+ }
+
+ /* ===== ชุดคำถามพิเศษ (รับการ์ดพิเศษ) — โมดูลกลาง ใช้ได้หลายเกม =====
+ * จุดต่อเข้าเกมจริง (trigger) จะ wire ภายหลังตามที่ผู้ใช้ระบุ
+ * วิธีใช้: SpecialQuiz.fetchSettings() -> getSet(level,caseId) -> pickQuestions(set,n)
+ * เล่นจบแล้ว awardCard(set, correct, total) -> set mission.specialCardAwarded = card
+ */
+ let __specialQuizSettingsCache = null;
+ let __specialQuizFetchPromise = null;
+
+ function specialQuizFetchSettings() {
+ if (__specialQuizSettingsCache) return Promise.resolve(__specialQuizSettingsCache);
+ if (__specialQuizFetchPromise) return __specialQuizFetchPromise;
+ __specialQuizFetchPromise = fetch(BASE + '/api/quiz-settings', { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __specialQuizSettingsCache = j || {}; return __specialQuizSettingsCache; })
+ .catch(function () { __specialQuizFetchPromise = null; return {}; });
+ return __specialQuizFetchPromise;
+ }
+
+ function specialQuizCurrentLevelCase() {
+ const meta = window.__detectiveLobbyMeta || {};
+ let level = Number(meta.level);
+ let caseId = Number(meta.caseId);
+ if (!(level >= 1 && level <= 5)) {
+ const lp = Number(params.get('lobbyLevel'));
+ if (lp >= 1 && lp <= 5) level = lp;
+ }
+ if (!(caseId >= 1 && caseId <= 3)) {
+ const cp = Number(params.get('case') || params.get('caseId'));
+ if (cp >= 1 && cp <= 3) caseId = cp;
+ }
+ return {
+ level: (level >= 1 && level <= 5) ? level : 1,
+ caseId: (caseId >= 1 && caseId <= 3) ? caseId : 1,
+ };
+ }
+
+ function specialQuizGetSet(settings, level, caseId) {
+ const sets = (settings && settings.specialQuizSets) || {};
+ const key = 'L' + level + '_C' + caseId;
+ return sets[key] || sets.L1_C1 || { questions: [], specialCard: {} };
+ }
+
+ function specialQuizPickQuestions(set, count) {
+ const qs = (set && Array.isArray(set.questions)) ? set.questions.slice() : [];
+ for (let i = qs.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const t = qs[i]; qs[i] = qs[j]; qs[j] = t;
+ }
+ let n = Number(count);
+ if (!(n >= 1)) n = qs.length;
+ return qs.slice(0, Math.min(n, qs.length));
+ }
+
+ /** กติกาได้การ์ด: ตอบถูก >= ครึ่งหนึ่ง และชุดนั้นตั้งการ์ดไว้ — คืน card หรือ null */
+ function specialQuizAwardCard(set, correctCount, totalCount) {
+ const card = (set && set.specialCard) || null;
+ if (!card || !(card.th || card.en || card.imageUrl)) return null;
+ const total = Number(totalCount) || 0;
+ const correct = Number(correctCount) || 0;
+ const passNeed = total > 0 ? Math.ceil(total / 2) : 1;
+ if (correct < passNeed) return null;
+ return card;
+ }
+
+ /** วาดการ์ดพิเศษลงในโซนโบนัสของสรุปผล (#gcm-bonus / .sm-bonus-right) */
+ function renderSpecialCardInBonus(bonusEl, card) {
+ if (!bonusEl || !card || !(card.th || card.en || card.imageUrl || card.cardId)) return;
+ const imgUrl = card.imageUrl || (card.cardId >= 1 && card.cardId <= 7
+ ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (!imgUrl) return;
+ const isPresetAsset = imgUrl.indexOf('/img/special-cards/') >= 0 || imgUrl.indexOf('/trycss/10-Game-1-result/') >= 0;
+ if (bonusEl.classList && bonusEl.classList.contains('gcm-bonus-mg2-right')) {
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ img.decoding = 'async';
+ bonusEl.appendChild(img);
+ return;
+ }
+ const wrap = document.createElement('div');
+ wrap.className = isPresetAsset
+ ? 'sm-bonus-right gcm-special-card-preset'
+ : 'gcm-special-card sm-bonus-special rarity-' + (card.rarity || 'rare');
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ wrap.appendChild(img);
+ if (!isPresetAsset) {
+ const cap = document.createElement('div');
+ cap.className = 'gcm-special-card-cap';
+ const strong = document.createElement('strong');
+ strong.textContent = card.th || '';
+ const span = document.createElement('span');
+ span.textContent = card.en || '';
+ cap.appendChild(strong);
+ cap.appendChild(span);
+ wrap.appendChild(cap);
+ }
+ bonusEl.appendChild(wrap);
+ }
+
+ window.SpecialQuiz = {
+ fetchSettings: specialQuizFetchSettings,
+ currentLevelCase: specialQuizCurrentLevelCase,
+ getSet: specialQuizGetSet,
+ pickQuestions: specialQuizPickQuestions,
+ awardCard: specialQuizAwardCard,
+ renderCardInBonus: renderSpecialCardInBonus,
+ };
+
+ /* ===== Special Quiz ในเกม — ไอคอนในฉาก (เดินชน) + overlay คำถาม multiplayer =====
+ * server เป็นผู้ตัดสิน: สุ่มไอคอน 1/3, เปิดคำถามให้ทุกคนพร้อมกัน, ตอบถูกทุกคนทุกข้อ -> ได้การ์ด
+ */
+ const SPECIAL_QUIZ_CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ /** Test Mode (Admin) เก็บใน localStorage — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
+ function __isTestModeOnPlay() {
+ try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
+ }
+ let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
+ let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน)
+ let specialQuizCollideSent = false;
+ let specialQuizCollideCooldownUntil = 0;
+ let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended)
+ let specialQuizActiveQuestion = null; // { index, total, choices, endsAt, iconType }
+ let specialQuizAnswered = false;
+ let specialQuizTimerRaf = 0;
+ const specialQuizIconImgs = {};
+ /** หยุดเกมทุกอย่างระหว่างตอบคำถามพิเศษ แล้ว resume หลังจบ */
+ let specialQuizFreeze = false;
+ let specialQuizFreezeStartMs = 0;
+ let specialQuizRoster = []; // [{ id, nickname, isBot }]
+ let specialQuizAnswerStatus = {}; // id -> true (ตอบแล้ว)
+
+ let specialQuizFreezeLastTickMs = 0;
+
+ function beginSpecialQuizFreeze() {
+ if (specialQuizFreeze) return;
+ specialQuizFreeze = true;
+ specialQuizFreezeStartMs = Date.now();
+ specialQuizFreezeLastTickMs = Date.now();
+ try { for (const k in keys) keys[k] = false; } catch (e) { /* ignore */ }
+ try { playPath = []; } catch (e) { /* ignore */ }
+ if (me) me.isWalking = false;
+ }
+
+ /** เลื่อน deadline/anchor เวลาทุกตัวไปข้างหน้าเท่า delta — ใช้เรียกทุกเฟรมระหว่าง freeze
+ เพื่อให้ "เวลาที่แสดง" หยุดนิ่ง (ไม่ใช่แค่ชดเชยตอนจบ) */
+ function shiftSpecialQuizFrozenTimers(deltaMs) {
+ if (!(deltaMs > 0)) return;
+ const shift = (v) => (typeof v === 'number' && v > 0) ? v + deltaMs : v;
+ try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {}
+ try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {}
+ try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {}
+ try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {}
+ try { if (typeof jumpSurviveSessionStartMs === 'number' && jumpSurviveSessionStartMs > 0) jumpSurviveSessionStartMs += deltaMs; } catch (e) {}
+ try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {}
+ }
+
+ /** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
+ function tickSpecialQuizFreezeShift() {
+ if (!specialQuizFreeze) return;
+ const now = Date.now();
+ shiftSpecialQuizFrozenTimers(now - specialQuizFreezeLastTickMs);
+ specialQuizFreezeLastTickMs = now;
+ }
+
+ function endSpecialQuizFreeze() {
+ if (!specialQuizFreeze) return;
+ /* ชดเชย delta ที่เหลือตั้งแต่เฟรมสุดท้ายจนถึงตอนนี้ */
+ shiftSpecialQuizFrozenTimers(Date.now() - specialQuizFreezeLastTickMs);
+ specialQuizFreeze = false;
+ specialQuizFreezeStartMs = 0;
+ specialQuizFreezeLastTickMs = 0;
+ }
+
+ function specialQuizIconUrl(type) {
+ return '/Game/img/special-quiz/icon-' + (type === 'police' ? 'police' : 'lawyer') + '.png';
+ }
+ function specialQuizGetIconImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizIconImgs[key]) return specialQuizIconImgs[key];
+ const img = new Image();
+ img.src = specialQuizIconUrl(key);
+ specialQuizIconImgs[key] = img;
+ return img;
+ }
+ const specialQuizGlowImgs = {};
+ /** แสง glow (08-event light-*) วางหลังไอคอน — ให้ดูเป็นของเก็บเรืองแสงบนเส้นทาง */
+ function specialQuizGetGlowImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizGlowImgs[key]) return specialQuizGlowImgs[key];
+ const img = new Image();
+ img.src = '/Game/img/special-quiz/light-' + key + '.png';
+ specialQuizGlowImgs[key] = img;
+ return img;
+ }
+ function setSpecialQuizIconFromServer(payload) {
+ if (!payload || payload.x == null || payload.y == null) { specialQuizIcon = null; return; }
+ specialQuizIcon = {
+ iconType: payload.iconType === 'police' ? 'police' : 'lawyer',
+ x: Number(payload.x),
+ y: Number(payload.y),
+ };
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ specialQuizGetIconImg(specialQuizIcon.iconType);
+ }
+ function clearSpecialQuizIcon() {
+ specialQuizIcon = null;
+ specialQuizIconScreenRect = null;
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ }
+
+ /** ส่ง collide ไป server เพื่อเปิดคำถาม — ถ้า server ปฏิเสธ (เช่น race) ให้ลองใหม่ได้หลัง cooldown */
+ function sendSpecialQuizCollide() {
+ if (specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (Date.now() < specialQuizCollideCooldownUntil) return;
+ specialQuizCollideSent = true;
+ specialQuizCollideCooldownUntil = Date.now() + 800;
+ try {
+ socket.emit('special-quiz-collide', {}, function (res) {
+ if (!(res && res.ok) && !specialQuizActiveQuestion) specialQuizCollideSent = false;
+ });
+ } catch (e) { specialQuizCollideSent = false; }
+ }
+
+ /** กด/แตะไอคอนควิซเพื่อเปิดคำถาม (ใช้ได้ทุกมินิเกม รวม space shooter ที่เดินไปแตะไม่ได้) */
+ function trySpecialQuizClickAt(clientX, clientY) {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return false;
+ if (!specialQuizIconScreenRect || !canvas) return false;
+ const r = canvas.getBoundingClientRect();
+ if (!r.width || !r.height) return false;
+ const px = (clientX - r.left) * (canvas.width / r.width);
+ const py = (clientY - r.top) * (canvas.height / r.height);
+ const dx = px - specialQuizIconScreenRect.x;
+ const dy = py - specialQuizIconScreenRect.y;
+ /* เผื่อระยะกดให้กว้างขึ้น — แตะใกล้ไอคอนก็เปิดได้ */
+ const hitR = specialQuizIconScreenRect.r * 1.3;
+ if (dx * dx + dy * dy <= hitR * hitR) {
+ sendSpecialQuizCollide();
+ return true;
+ }
+ return false;
+ }
+
+ /** วาดไอคอนในฉาก — ใช้ worldToScreen + zDraw จาก draw() เพื่อให้ตรงทุกมินิเกม */
+ function drawSpecialQuizIconWorld(w2s, zDraw) {
+ if (!specialQuizIcon || typeof w2s !== 'function') return;
+ const img = specialQuizGetIconImg(specialQuizIcon.iconType);
+ const sc = w2s(specialQuizIcon.x * tileSize, specialQuizIcon.y * tileSize);
+ const sx = sc[0];
+ const sy = sc[1];
+ if (!Number.isFinite(sx) || !Number.isFinite(sy)) return;
+ const size = Math.max(30, tileSize * zDraw * 1.75);
+ const bob = Math.sin(Date.now() / 320) * size * 0.06;
+ const cy = sy + bob;
+ ctx.save();
+ /* glow 08-event (light-*) หมุน+เต้นหลังไอคอน — collectible เรืองแสง */
+ const glowImg = specialQuizGetGlowImg(specialQuizIcon.iconType);
+ if (glowImg && glowImg.complete && glowImg.naturalWidth > 0) {
+ const pulse = 0.82 + Math.sin(Date.now() / 300) * 0.18;
+ const gsz = size * 2.5 * pulse;
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ ctx.globalAlpha = 0.9;
+ ctx.translate(sx, cy);
+ ctx.rotate(Date.now() / 2600);
+ ctx.drawImage(glowImg, -gsz / 2, -gsz / 2, gsz, gsz);
+ ctx.restore();
+ }
+ const r = size * 0.62;
+ const grad = ctx.createRadialGradient(sx, cy, r * 0.18, sx, cy, r);
+ grad.addColorStop(0, 'rgba(122, 162, 247, 0.5)');
+ grad.addColorStop(1, 'rgba(122, 162, 247, 0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ /* เก็บตำแหน่งบนจอไว้ให้กด/แตะไอคอนได้ (สำหรับมินิเกมที่เดินไปแตะไม่ได้ เช่น space shooter) */
+ specialQuizIconScreenRect = { x: sx, y: cy, r: Math.max(size * 0.7, 36) };
+ if (img && img.complete && img.naturalWidth > 0) {
+ ctx.drawImage(img, sx - size / 2, cy - size / 2, size, size);
+ } else {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.fillRect(sx - size / 2, cy - size / 2, size, size);
+ }
+ ctx.fillStyle = '#ffd166';
+ ctx.font = '700 ' + Math.round(size * 0.42) + 'px system-ui, "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('?', sx + size * 0.4, cy - size * 0.38);
+ ctx.restore();
+ }
+
+ /** เช็คผู้เล่นเดินชนไอคอน — ใครชนก่อนเป็นคนเปิดคำถามให้ทั้งห้อง (server กันซ้ำ) */
+ function checkSpecialQuizCollision() {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (!Number.isFinite(me.x) || !Number.isFinite(me.y)) return;
+ const dx = me.x - specialQuizIcon.x;
+ const dy = me.y - specialQuizIcon.y;
+ /* รัศมีกว้างขึ้น (1.25 tile) — ไอคอนวาดใหญ่ 1.75 tile เดินเข้าใกล้ก็ทริกได้ ไม่ต้องทับ tile กลางพอดี */
+ if (dx * dx + dy * dy <= 1.25 * 1.25) {
+ sendSpecialQuizCollide();
+ }
+ }
+
+ function specialQuizOverlayEl() { return document.getElementById('special-quiz-overlay'); }
+
+ function specialQuizSelfId() { return myId != null ? String(myId) : null; }
+
+ function specialQuizMemberLabel(m) {
+ if (m && m.id && m.id === specialQuizSelfId()) return (m.nickname || 'คุณ') + ' (คุณ)';
+ return (m && m.nickname) ? m.nickname : '—';
+ }
+
+ function renderSpecialQuizMembers() {
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ box.innerHTML = '';
+ specialQuizRoster.forEach(function (m, i) {
+ const row = document.createElement('div');
+ row.className = 'sq-ov-member' + (m.id === specialQuizSelfId() ? ' sq-ov-member--me' : '');
+ row.dataset.id = m.id;
+ const dot = document.createElement('span');
+ dot.className = 'sq-ov-member-dot';
+ dot.textContent = String(i + 1);
+ const name = document.createElement('span');
+ name.className = 'sq-ov-member-name';
+ name.textContent = specialQuizMemberLabel(m);
+ const tag = document.createElement('span');
+ tag.className = 'sq-ov-member-tag';
+ tag.textContent = m.isBot ? 'บอท' : '';
+ const ans = document.createElement('span');
+ ans.className = 'sq-ov-member-ans';
+ ans.textContent = specialQuizAnswerStatus[m.id] ? '…' : '';
+ if (specialQuizAnswerStatus[m.id]) row.classList.add('sq-ov-member--answered');
+ row.appendChild(dot);
+ row.appendChild(name);
+ row.appendChild(tag);
+ row.appendChild(ans);
+ box.appendChild(row);
+ });
+ }
+
+ function markSpecialQuizMemberAnswered(id) {
+ if (!id) return;
+ specialQuizAnswerStatus[id] = true;
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ const row = box.querySelector('.sq-ov-member[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
+ if (!row) { renderSpecialQuizMembers(); return; }
+ row.classList.add('sq-ov-member--answered');
+ const ans = row.querySelector('.sq-ov-member-ans');
+ if (ans && !row.classList.contains('sq-ov-member--correct') && !row.classList.contains('sq-ov-member--wrong')) ans.textContent = '✓';
+ }
+
+ function openSpecialQuizQuestion(q) {
+ if (!q) return;
+ clearSpecialQuizIcon();
+ beginSpecialQuizFreeze();
+ specialQuizActiveQuestion = {
+ index: q.index | 0,
+ total: q.total | 0,
+ choices: Array.isArray(q.choices) ? q.choices : [],
+ endsAt: Number(q.endsAt) || 0,
+ iconType: q.iconType,
+ };
+ if (Array.isArray(q.roster) && q.roster.length) specialQuizRoster = q.roster.slice();
+ specialQuizAnswerStatus = {};
+ specialQuizAnswered = false;
+ const ov = specialQuizOverlayEl();
+ if (!ov) return;
+ const iconEl = document.getElementById('sq-ov-icon');
+ if (iconEl) iconEl.src = specialQuizIconUrl(q.iconType);
+ const prog = document.getElementById('sq-ov-progress');
+ if (prog) prog.textContent = 'ข้อ ' + ((q.index | 0) + 1) + ' / ' + (q.total | 0);
+ const qEl = document.getElementById('sq-ov-question');
+ if (qEl) qEl.textContent = q.text || '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) { result.classList.add('is-hidden'); result.textContent = ''; result.className = 'sq-ov-result is-hidden'; }
+ /* แสดงเฉพาะ layer คำถาม/คำตอบ — ซ่อน layer ผลรางวัล */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.remove('is-hidden');
+ const awardLayer = document.getElementById('sq-ov-award');
+ if (awardLayer) awardLayer.classList.add('is-hidden');
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'เลือกคำตอบ — ทุกคนต้องตอบถูกทุกข้อจึงได้การ์ดพิเศษ';
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ choicesEl.innerHTML = '';
+ specialQuizActiveQuestion.choices.forEach(function (c, i) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'sq-ov-choice';
+ b.dataset.idx = String(i);
+ const k = document.createElement('span');
+ k.className = 'sq-ov-choice-key';
+ k.textContent = SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1);
+ const t = document.createElement('span');
+ t.className = 'sq-ov-choice-text';
+ t.textContent = c;
+ b.appendChild(k);
+ b.appendChild(t);
+ b.onclick = function () { submitSpecialQuizAnswer(i); };
+ choicesEl.appendChild(b);
+ });
+ }
+ renderSpecialQuizMembers();
+ ov.classList.remove('is-hidden');
+ startSpecialQuizTimerLoop();
+ }
+
+ function submitSpecialQuizAnswer(i) {
+ if (specialQuizAnswered || !specialQuizActiveQuestion) return;
+ specialQuizAnswered = true;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ b.classList.toggle('sq-ov-choice--picked', Number(b.dataset.idx) === i);
+ b.disabled = true;
+ });
+ }
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'ส่งคำตอบแล้ว — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ try { socket.emit('special-quiz-answer', { choiceIndex: i }, function () {}); } catch (e) { /* ignore */ }
+ }
+
+ function startSpecialQuizTimerLoop() {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ function tick() {
+ if (!specialQuizActiveQuestion) return;
+ const timerEl = document.getElementById('sq-ov-timer');
+ const ms = specialQuizActiveQuestion.endsAt - Date.now();
+ const s = Math.max(0, Math.ceil(ms / 1000));
+ if (timerEl) {
+ timerEl.textContent = String(s);
+ timerEl.classList.toggle('sq-ov-timer--low', s <= 5);
+ }
+ if (ms > 0) specialQuizTimerRaf = requestAnimationFrame(tick);
+ }
+ tick();
+ }
+
+ function handleSpecialQuizResult(r) {
+ if (!r || !specialQuizActiveQuestion) return;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ const idx = Number(b.dataset.idx);
+ b.disabled = true;
+ if (idx === r.correctIndex) b.classList.add('sq-ov-choice--correct');
+ else if (b.classList.contains('sq-ov-choice--picked')) b.classList.add('sq-ov-choice--wrong');
+ });
+ }
+ /* เฉลย — เปิดเผยว่าแต่ละคนตอบอะไร ถูก/ผิด */
+ const answers = (r.answers && typeof r.answers === 'object') ? r.answers : {};
+ const labels = SPECIAL_QUIZ_CHOICE_LABELS;
+ const box = document.getElementById('sq-ov-members');
+ if (box) {
+ Array.prototype.forEach.call(box.children, function (row) {
+ const id = row.dataset.id;
+ const a = answers[id];
+ const ansEl = row.querySelector('.sq-ov-member-ans');
+ row.classList.remove('sq-ov-member--answered');
+ if (Number.isInteger(a)) {
+ const ok = a === r.correctIndex;
+ row.classList.add(ok ? 'sq-ov-member--correct' : 'sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = (ok ? '✓ ' : '✗ ') + (labels[a] || String(a + 1));
+ } else {
+ row.classList.add('sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = '—';
+ }
+ });
+ }
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = r.allCorrect
+ ? 'ทุกคนตอบถูก!'
+ : (r.timeout ? 'หมดเวลา — ตอบไม่ครบทุกคน' : 'มีคนตอบผิด — ไม่ได้การ์ดพิเศษ');
+ result.className = 'sq-ov-result ' + (r.allCorrect ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ cancelAnimationFrame(specialQuizTimerRaf);
+ }
+
+ /** การ์ดพิเศษถูก "ใช้" — โชว์ animation + ขยายเวลามินิเกมฝั่ง client (ถ้าเป็น Time Dilation) */
+ function applySpecialCardClient(data) {
+ const card = data.card || {};
+ const addSec = Number(data.addSec) || 0;
+ const coins = Number(data.coins) || 0;
+ if (addSec > 0 && card.effectKey === 'time_dilation') {
+ extendCurrentMinigameTimeClient(addSec);
+ }
+ if (coins > 0 && card.effectKey === 'fund') {
+ /* server แจกเหรียญแล้ว (game-award.php) — แค่ดึงยอดล่าสุดมาแสดง; ถ้าไม่ใช่ (เผื่อ fallback) ค่อยบวกเอง */
+ if (data.coinsServerAwarded) resyncOwnCoins();
+ else addOwnCoinsViaApi(coins);
+ }
+ /* ถ้า overlay ควิซยังเปิด (การ์ดเพิ่งได้จากควิซ) — แผง «เล่นต่อ» โชว์การ์ดอยู่แล้ว ไม่ต้องเด้งซ้ำ */
+ const sqOv = specialQuizOverlayEl();
+ if (sqOv && !sqOv.classList.contains('is-hidden')) return;
+ showSpecialCardUseAnimation(card, { addSec: addSec, coins: coins });
+ }
+
+ /** Card 6 Fund — แต่ละ client บวกเหรียญของตัวเอง (อ่าน jdPlayerKey จาก localStorage) */
+ function addOwnCoinsViaApi(amount) {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins-add.php') : '/Admin/api/player-coins-add.php');
+ try {
+ fetch(url, {
+ method: 'POST',
+ credentials: 'omit',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playerKey: key, amount: amount }),
+ }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** ดึงยอดเหรียญล่าสุดจาก server มาอัปเดต cache (ใช้หลัง server แจกเหรียญเอง เช่น Fund) */
+ function resyncOwnCoins() {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key);
+ try {
+ fetch(url, { credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */
+ function extendCurrentMinigameTimeClient(sec) {
+ const ms = Math.max(0, sec * 1000);
+ if (!ms) return;
+ if (isJumpSurvive() && jumpSurviveSessionStartMs) jumpSurviveSessionStartMs += ms;
+ if (isSpaceShooter() && spaceShooterSessionStartMs) spaceShooterSessionStartMs += ms;
+ if (isQuizCarry()) {
+ if (quizCarryAnswerCloseAt > 0) quizCarryAnswerCloseAt += ms;
+ if (quizCarryOptionRevealAt > 0) quizCarryOptionRevealAt += ms;
+ }
+ }
+
+ function showSpecialCardUseAnimation(card, opts) {
+ opts = opts || {};
+ let ov = document.getElementById('special-card-use-overlay');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'special-card-use-overlay';
+ ov.className = 'is-hidden';
+ ov.innerHTML =
+ '' +
+ '
';
+ document.body.appendChild(ov);
+ }
+ const img = ov.querySelector('.scu-img');
+ const nameEl = ov.querySelector('.scu-name');
+ const fxEl = ov.querySelector('.scu-effect');
+ if (img) img.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (nameEl) nameEl.textContent = 'ใช้การ์ด: ' + (card.en || card.th || 'Special Card');
+ if (fxEl) {
+ let fx = card.desc || card.th || '';
+ if (opts.addSec > 0) fx = '+' + opts.addSec + ' วินาที';
+ else if (opts.coins > 0) fx = 'ทุกคน +' + opts.coins + ' COINS';
+ fxEl.textContent = fx;
+ }
+ ov.classList.remove('is-hidden');
+ ov.classList.remove('scu-play');
+ void ov.offsetWidth;
+ ov.classList.add('scu-play');
+ clearTimeout(showSpecialCardUseAnimation._t);
+ showSpecialCardUseAnimation._t = setTimeout(function () {
+ ov.classList.add('is-hidden');
+ ov.classList.remove('scu-play');
+ }, 2600);
+ }
+
+ /** ปิด overlay + คืนเวลาเล่นต่อ (เรียกตอนกด «เล่นต่อ» หรือ safety timeout) */
+ function specialQuizContinueAndResume() {
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = 0;
+ const ov = specialQuizOverlayEl();
+ const award = document.getElementById('sq-ov-award');
+ if (award) award.classList.add('is-hidden');
+ if (ov) ov.classList.add('is-hidden');
+ /* แจ้ง server ให้เล่นรอบ quiz (mng8a80o) ต่อ — มินิเกมอื่น freeze ฝั่ง client พอ */
+ try { socket.emit('special-quiz-continue', {}, function () {}); } catch (e) { /* ignore */ }
+ endSpecialQuizFreeze();
+ }
+
+ function endSpecialQuiz(data) {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ specialQuizActiveQuestion = null;
+ specialQuizAnswered = false;
+ const success = !!(data && data.success && data.card);
+ const card = success ? data.card : null;
+ if (success) specialQuizAwardedCard = card;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = success ? 'ได้รับการ์ดพิเศษ!' : 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ result.className = 'sq-ov-result ' + (success ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ /* แผงโชว์การ์ด + ปุ่ม «เล่นต่อ» — เกมหยุดค้างจนกว่าจะกด */
+ const award = document.getElementById('sq-ov-award');
+ const titleEl = document.getElementById('sq-ov-award-title');
+ const imgEl = document.getElementById('sq-ov-award-img');
+ const nameEl = document.getElementById('sq-ov-award-name');
+ const fxEl = document.getElementById('sq-ov-award-effect');
+ const btn = document.getElementById('sq-ov-award-continue');
+ if (award) {
+ if (success) {
+ if (titleEl) titleEl.textContent = 'ได้รับการ์ดพิเศษ!';
+ if (imgEl) {
+ imgEl.removeAttribute('hidden');
+ imgEl.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ }
+ if (nameEl) nameEl.textContent = card.en || card.th || ('การ์ด #' + (card.cardId || ''));
+ if (fxEl) fxEl.textContent = card.desc || card.th || '';
+ } else {
+ if (titleEl) titleEl.textContent = 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ if (imgEl) { imgEl.setAttribute('hidden', ''); imgEl.removeAttribute('src'); }
+ if (nameEl) nameEl.textContent = '';
+ if (fxEl) fxEl.textContent = 'ตอบถูกครบทุกคนเพื่อรับการ์ดพิเศษในครั้งหน้า';
+ }
+ if (btn) btn.onclick = specialQuizContinueAndResume;
+ /* สลับ layer: ซ่อนคำถาม/คำตอบ แล้วโชว์ผลรางวัลเต็มหน้า */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.add('is-hidden');
+ award.classList.remove('is-hidden');
+ } else {
+ /* ไม่มี element สำรอง — เล่นต่อทันที */
+ specialQuizContinueAndResume();
+ return;
+ }
+ /* กันค้าง: ถ้าไม่กดภายใน 30 วิ ให้ไปต่อเอง */
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = setTimeout(specialQuizContinueAndResume, 30000);
+ }
+
+ function showGauntletCrownMissionOverlay(mission) {
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ const rowEl = document.getElementById('gcm-rank-row');
+ const totalEl = document.getElementById('gcm-total');
+ const gradeEl = document.getElementById('gcm-grade');
+ const bonusEl = document.getElementById('gcm-bonus');
+ const btn = document.getElementById('btn-gcm-done');
+ if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return;
+ if (!mission.uiSkin && isGauntletCrownHeistMapPlay()) mission.uiSkin = 'gauntlet_crown';
+ const missionSkin = resolveGauntletCrownMissionUiSkinPlay(mission);
+ if (!mission.specialCardAwarded && specialQuizAwardedCard) mission.specialCardAwarded = specialQuizAwardedCard;
+ let disp = gauntletCrownBuildDisplayMission(mission);
+ if (mission.uiSkin === 'jumper') {
+ disp = jumpSurviveMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'violent_crime') {
+ disp = spaceShooterMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'question_mission') {
+ disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'stack_tower') {
+ disp = stackTowerMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'mega_virus') {
+ disp = balloonBossMergePreviewBotsMission(disp) || disp;
+ }
+ if (!disp || !Array.isArray(disp.ranked)) return;
+ /* mission map — สรุปผลภารกิจตาม mockup (Minigame 1/4–7 + Last Light 07-summary) */
+ const tfMockSummary = isGcmMissionMockSummaryPlay(mission);
+ const mg2Summary = tfMockSummary && missionSkin === 'gauntlet_crown';
+ ov.classList.toggle('gcm-quiz-tf-mock', tfMockSummary);
+ ov.classList.toggle('gcm-mg2-mock', mg2Summary);
+ const gcmVrule = document.getElementById('gcm-vrule');
+ if (gcmVrule) {
+ gcmVrule.classList.toggle('is-hidden', !mg2Summary);
+ gcmVrule.setAttribute('aria-hidden', mg2Summary ? 'true' : 'true');
+ }
+ const gcmBgImg = ov.querySelector('.gcm-bg');
+ if (gcmBgImg) {
+ const gcmBgSrc = tfMockSummary
+ ? gcmMissionMockAssetUrl(mission, 'popup-result.png')
+ : (BASE + '/img/gauntlet-assets/popup-result.png');
+ if (gcmBgImg.getAttribute('src') !== gcmBgSrc) gcmBgImg.setAttribute('src', gcmBgSrc);
+ }
+ if (tfMockSummary) {
+ ensureMePlayTintFromLobbyStorage();
+ ensureQuizTfScaleResizeListener();
+ updateGcmTfMockScale(ov);
+ /* preload tint ทุกคนก่อนวาด avatar — กันรูปว่าง/สีเดียว */
+ disp.ranked.forEach(function (r) {
+ const cid = resolveRankedCharacterIdPlay(r);
+ if (!cid) return;
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }));
+ });
+ } else {
+ ov.style.removeProperty('--gcm-tf-scale');
+ }
+ rowEl.innerHTML = '';
+ disp.ranked.forEach((r) => {
+ const cell = document.createElement('div');
+ cell.className = 'gcm-cell';
+ const tag = document.createElement('div');
+ tag.className = 'gcm-rank-tag';
+ const rk = Math.floor(Number(r.rank)) || 0;
+ if (tfMockSummary) {
+ /* mockup: ทุกอันดับเป็นภาพ (result-1st/2nd/3rd, txt-1=4, txt-2=5+) */
+ const badge = rk === 1 ? 'result-1st.png' : rk === 2 ? 'result-2nd.png'
+ : rk === 3 ? 'result-3rd.png' : rk === 4 ? 'result-txt-1.png' : 'result-txt-2.png';
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmMissionMockAssetUrl(mission, badge);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else if (rk >= 1 && rk <= 3) {
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmRankBadgeUrl(rk);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else {
+ tag.textContent = '[' + String(r.rankLabel || rk) + ']';
+ }
+ const frame = document.createElement('div');
+ frame.className = 'gcm-frame';
+ const img = document.createElement('img');
+ img.className = 'gcm-av';
+ img.alt = '';
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ if (cid) {
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ } else {
+ img.src = defaultAvatarImg.src;
+ }
+ img.onerror = function () {
+ this.onerror = null;
+ applyCyberHudScoreAvatarImgWithRetry(this, avRow, 3);
+ };
+ if (tfMockSummary) {
+ /* mission mock: หน้าใน gcm-av-clip z3 ทับ player-frame z2 (กลางกรอบทึบ) */
+ const avClip = document.createElement('div');
+ avClip.className = 'gcm-av-clip';
+ avClip.appendChild(img);
+ frame.appendChild(avClip);
+ const frameImg = document.createElement('img');
+ frameImg.className = 'gcm-frame-img';
+ frameImg.src = gcmMissionMockAssetUrl(mission, 'player-frame.png');
+ frameImg.alt = '';
+ frameImg.decoding = 'async';
+ frame.appendChild(frameImg);
+ } else {
+ frame.appendChild(img);
+ }
+ const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong;
+ if (tfMockSummary && (r.eliminated || qMissionLose)) cell.classList.add('gcm-cell--out');
+ if (r.eliminated || qMissionLose) {
+ const st = document.createElement('img');
+ st.className = 'gcm-stamp';
+ const jSkin = mission && mission.uiSkin === 'jumper';
+ let loseUrl = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ let altLose = 'เสียชีวิต';
+ if (jSkin && r.eliminated) {
+ loseUrl = jumperAssetUrl('stamp-sacrifice.png');
+ altLose = 'ผู้เสียสละ';
+ } else if (qMissionLose) {
+ loseUrl = BASE + '/img/QUESTION/result-Lose_stamp.png';
+ altLose = 'ตอบผิด';
+ }
+ st.src = loseUrl;
+ st.alt = altLose;
+ st.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ this.alt = 'ตอบผิด';
+ };
+ frame.appendChild(st);
+ }
+ const nick = document.createElement('div');
+ nick.className = 'gcm-nick';
+ nick.textContent = r.nickname || '—';
+ const sc = document.createElement('div');
+ sc.className = 'gcm-sc';
+ const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus');
+ const botScorePlain = (playBotsEnabled() && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id) && !mg2Summary)
+ || (playBotsEnabled() && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id));
+ if (mg2Summary) {
+ const bs = Math.max(0, Number(r.baseScore) || 0);
+ const rb = Math.max(0, Number(r.rankBonus) || 0);
+ sc.textContent = '';
+ sc.appendChild(document.createTextNode(String(bs)));
+ if (rb > 0) {
+ const bon = document.createElement('span');
+ bon.className = 'gcm-sc-bonus';
+ bon.textContent = '(+' + String(rb) + ')';
+ sc.appendChild(bon);
+ }
+ } else {
+ sc.textContent = (jumpPlain || botScorePlain)
+ ? String(Math.max(0, Number(r.baseScore) || 0))
+ : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')';
+ }
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ if (totalEl) {
+ const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0)));
+ const sum = Math.max(0, Number(disp.totalSum) || 0);
+ const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0));
+ const jumperSkin = mission && mission.uiSkin === 'jumper';
+ const violentSkin = mission && mission.uiSkin === 'violent_crime';
+ const questionMissionSkin = mission && mission.uiSkin === 'question_mission';
+ if (tfMockSummary) {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') =' + sum + ' ';
+ } else if (jumperSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด';
+ } else if (violentSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · รอดชีวิต ' + sc + '/' + pc + ' → เกรด';
+ } else if (questionMissionSkin) {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนตอบคำถาม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'stack_tower') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'mega_virus') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด';
+ }
+ }
+ if (gradeEl) {
+ const g = String(disp.grade || 'C').toUpperCase().charAt(0);
+ gradeEl.textContent = g;
+ gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase();
+ }
+ if (bonusEl) {
+ bonusEl.innerHTML = '';
+ const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0);
+ const missionOk = gLetter !== 'F';
+ let bonusCardHost = bonusEl;
+ if (mg2Summary && missionOk) {
+ const bonusWrap = document.createElement('div');
+ bonusWrap.className = 'gcm-bonus-mg2 sm-bonus';
+ const bonusLeft = document.createElement('div');
+ bonusLeft.className = 'gcm-bonus-mg2-left sm-bonus-left';
+ const bonusHead = document.createElement('h3');
+ bonusHead.className = 'gcm-h gcm-bonus-mg2-head head';
+ bonusHead.textContent = 'โบนัสพิเศษ';
+ const completeImg = document.createElement('img');
+ completeImg.className = 'gcm-bonus-mg2-complete complete';
+ completeImg.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ completeImg.alt = 'ภารกิจสำเร็จ';
+ completeImg.decoding = 'async';
+ completeImg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ bonusLeft.appendChild(bonusHead);
+ bonusLeft.appendChild(completeImg);
+ const bonusRight = document.createElement('div');
+ bonusRight.className = 'gcm-bonus-mg2-right sm-bonus-right';
+ bonusWrap.appendChild(bonusLeft);
+ bonusWrap.appendChild(bonusRight);
+ bonusEl.appendChild(bonusWrap);
+ bonusCardHost = bonusRight;
+ } else if (missionOk) {
+ const row = document.createElement('div');
+ row.className = 'gcm-bonus-okrow';
+ row.style.display = 'flex';
+ row.style.alignItems = 'center';
+ row.style.gap = '8px';
+ const chk = document.createElement('img');
+ const stackTS = mission && mission.uiSkin === 'stack_tower';
+ const megaVs = mission && mission.uiSkin === 'mega_virus';
+ chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png'));
+ chk.alt = '';
+ if (stackTS || megaVs) {
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ }
+ if (tfMockSummary) {
+ chk.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ chk.alt = 'ภารกิจสำเร็จ';
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ row.appendChild(chk);
+ } else {
+ chk.width = 26;
+ chk.height = 26;
+ const sp = document.createElement('span');
+ sp.style.color = '#9ece6a';
+ sp.style.fontWeight = '700';
+ sp.textContent = 'ภารกิจสำเร็จ';
+ row.appendChild(chk);
+ row.appendChild(sp);
+ }
+ bonusEl.appendChild(row);
+ }
+ const rc = disp.rewardCard;
+ if (!mg2Summary && missionOk && rc && rc.th) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.innerHTML = '' + String(rc.th) + ' ' + String(rc.en || '') + ' ';
+ bonusEl.appendChild(pl);
+ } else if (!mg2Summary && !missionOk) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.style.borderColor = 'rgba(148, 153, 168, 0.45)';
+ pl.style.opacity = '0.95';
+ pl.innerHTML = 'ไม่ได้รับการ์ด เกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์ ';
+ bonusEl.appendChild(pl);
+ }
+ if (mission && mission.specialCardAwarded) {
+ renderSpecialCardInBonus(bonusCardHost, mission.specialCardAwarded);
+ } else if (mg2Summary && missionOk && rc && rc.th) {
+ const cardId = rc.kind === 'culprit' ? 1 : (rc.kind === 'rare' ? 5 : 3);
+ renderSpecialCardInBonus(bonusCardHost, {
+ th: rc.th,
+ en: rc.en || '',
+ imageUrl: '/Game/img/special-cards/card-' + cardId + '.png',
+ cardId: cardId,
+ rarity: rc.kind === 'rare' ? 'rare' : (rc.kind === 'culprit' ? 'legendary' : 'common'),
+ });
+ }
+ }
+ const gcmHead = document.getElementById('gcm-heading');
+ if (gcmHead) {
+ if (mission && (mission.uiSkin === 'gauntlet_crown' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) gcmHead.classList.add('sr-only');
+ else gcmHead.classList.remove('sr-only');
+ }
+ if (btn && mg2Summary) {
+ btn.style.backgroundImage = "url('" + gcmMissionMockAssetUrl(mission, 'btn-done.png') + "')";
+ } else if (btn) {
+ btn.style.removeProperty('background-image');
+ }
+ ov.classList.remove('is-hidden');
+ if (tfMockSummary) {
+ requestAnimationFrame(function () {
+ refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked);
+ });
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 200);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 550);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 1200);
+ }
+ function goLobby() {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = function () {
+ if (previewMode && editorEmbedReturn) {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'quiz_carry')) {
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ } else {
+ showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); });
+ }
+ } else {
+ goLobby();
+ }
+ };
+ }
+ }
+
+ function isLobby() { return mapData && mapData.gameType === 'lobby'; }
+ function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
+ function getLane(y) {
+ if (!mapData || !mapData.lanes) return null;
+ const lane = mapData.lanes.find(l => l.y === y);
+ return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
+ }
+ function getVehiclePositions(lane, width, timeMs) {
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
+ const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
+ const dir = lane.dir === -1 ? -1 : 1;
+ const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
+ const period = width + 2;
+ const count = Math.max(2, Math.floor(period / spacing));
+ const positions = [];
+ for (let i = 0; i < count; i++) {
+ const p = i * spacing + (timeMs * speed * dir) / 60;
+ const pNorm = ((p % period) + period) % period;
+ const vx = pNorm - 1;
+ positions.push(Math.max(-1, Math.min(width, vx)));
+ }
+ return positions;
+ }
+ function checkFroggerCollision() {
+ const fy = Math.floor(me.y);
+ const lane = getLane(fy);
+ if (!lane) return false;
+ if (lane.type === 'road') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.1) return true;
+ }
+ }
+ if (lane.type === 'water') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ let onLog = false;
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
+ }
+ if (!onLog) return true;
+ }
+ return false;
+ }
+ function respawnFrogger() {
+ const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
+ me.x = sp.x; me.y = sp.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+
+ /** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
+ function peerXYFromJoin(peer, spawnFb) {
+ const sx = Number(spawnFb && spawnFb.x);
+ const sy = Number(spawnFb && spawnFb.y);
+ const defX = Number.isFinite(sx) ? sx : 1;
+ const defY = Number.isFinite(sy) ? sy : 1;
+ if (!peer) return { x: defX, y: defY };
+ const x = Number(peer.x);
+ const y = Number(peer.y);
+ if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+ return { x: defX, y: defY };
+ }
+
+ /** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
+ function normalizeQuizCarryLayersInPlay(md) {
+ if (!md || md.gameType !== 'quiz_carry') return;
+ const w = md.width || 20, h = md.height || 15;
+ const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
+ const srcHub = md.quizCarryHubArea || [];
+ const hubRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcHub[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ hubRows.push(row);
+ }
+ md.quizCarryHubArea = hubRows;
+ const srcOpt = md.quizCarryOptionArea || [];
+ const optRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcOpt[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
+ }
+ optRows.push(row);
+ }
+ md.quizCarryOptionArea = optRows;
+ const srcCd = md.carryEmbedCountdownArea || [];
+ const cdRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcCd[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ cdRows.push(row);
+ }
+ md.carryEmbedCountdownArea = cdRows;
+ }
+
+ /** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
+ function normalizeQuizBattleDomeInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattleDomeArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattleDomeArea = grid;
+ /* 1) flood-fill หากลุ่มโดม (raw component = 1 สถานีคำถาม) */
+ const raw = Array(h).fill(0).map(() => Array(w).fill(0));
+ const compsCells = []; // compsCells[i] = [[x,y],...] ของกลุ่มที่ i (rawId = i+1)
+ let rawId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (grid[y][x] !== 1 || raw[y][x]) continue;
+ rawId++;
+ const cells = [];
+ const stack = [[x, y]];
+ raw[y][x] = rawId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ cells.push([cx, cy]);
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (grid[ny][nx] !== 1 || raw[ny][nx]) continue;
+ raw[ny][nx] = rawId;
+ stack.push([nx, ny]);
+ }
+ }
+ compsCells.push(cells);
+ }
+ }
+ /* 2) BFS ระยะทางจากจุดเกิด (เดินบนเส้นทาง+โดม) เพื่อจัดเลขโดมตามลำดับ "เดินถึงก่อน = เลขน้อยกว่า" */
+ const path = md.quizBattlePathArea;
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const bq = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ const walkable = (tx, ty) => {
+ const onPath = path && path[ty] && path[ty][tx] === 1;
+ const onDome = grid[ty] && grid[ty][tx] === 1;
+ return onPath || onDome;
+ };
+ const enq = (tx, ty, d) => {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0 || !walkable(tx, ty)) return;
+ dist[ty][tx] = d; bq.push([tx, ty, d]);
+ };
+ enq(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) enq(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) if (row[tx] === 1) enq(tx, ty, 0); }
+ }
+ let head = 0;
+ while (head < bq.length) {
+ const cur = bq[head++];
+ const cx = cur[0], cy = cur[1], cd = cur[2];
+ enq(cx + 1, cy, cd + 1); enq(cx - 1, cy, cd + 1); enq(cx, cy + 1, cd + 1); enq(cx, cy - 1, cd + 1);
+ }
+ /* 3) ระยะทางต่ำสุดของแต่ละกลุ่ม → เรียง → ให้เลข 1..N (กลุ่มที่ไปไม่ถึงไว้ท้าย) */
+ const order = compsCells.map((cells, i) => {
+ let md0 = Infinity;
+ for (const [cx, cy] of cells) { const dd = dist[cy][cx]; if (dd >= 0 && dd < md0) md0 = dd; }
+ return { idx: i, minDist: md0, firstCell: cells[0] };
+ });
+ order.sort((a, b) => (a.minDist - b.minDist) || (a.firstCell[1] - b.firstCell[1]) || (a.firstCell[0] - b.firstCell[0]));
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ order.forEach((o, rank) => { for (const [cx, cy] of compsCells[o.idx]) comp[cy][cx] = rank + 1; });
+ md.quizBattleDomeComp = comp;
+ }
+
+ function hasQuizBattleManualPathGatesPlay(md) {
+ const mg = md && md.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ function buildQuizBattlePathGateFromManualPlay(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const manual = md.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ /** ประตูบนเลน — เลนหลังโดม C ต้องตอบถูกโดม 1..C ก่อน (ยืนบนโดมได้โดยไม่ต้องตอบโดมนั้น) */
+ function buildQuizBattlePathGateRequiredPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) {
+ if (md) md.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGatesPlay(md)) {
+ buildQuizBattlePathGateFromManualPlay(md);
+ return;
+ }
+ normalizeQuizBattleDomeInPlay(md);
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const domeComp = md.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ function quizBattlePathGateRequiredAtPlay(md, px, py) {
+ const gate = md && md.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActive(md)) return 0;
+ const path = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ let maxReq = 0;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+ }
+
+ function quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) {
+ const maxReq = quizBattlePathGateRequiredAtPlay(md, px, py);
+ if (maxReq <= 0) return 0;
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solvedSet.has(c)) return c;
+ }
+ return 0;
+ }
+
+ function quizBattlePathGateAllowsPlay(md, px, py, solvedSet) {
+ if (quizBattleFootprintOnDomePlay(md, px, py)) return true;
+ return quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) === 0;
+ }
+
+ /**
+ * คำถามของโดม compId → index ใน pool — เคารพ "คำถามที่กำหนดเองต่อโดม" (quizBattleDomeQuestionArea) จาก Editor
+ * ถ้าไม่กำหนด (0) ใช้ตามลำดับเดิน (compId-1)%len เหมือนเดิม
+ */
+ function quizBattleQuestionIndexForCompPlay(compId, poolLen) {
+ if (!(poolLen > 0)) return 0;
+ let override = 0;
+ const md = mapData;
+ if (md && Array.isArray(md.quizBattleDomeComp) && Array.isArray(md.quizBattleDomeQuestionArea)) {
+ const comp = md.quizBattleDomeComp, qa = md.quizBattleDomeQuestionArea;
+ for (let y = 0; y < comp.length && !override; y++) {
+ const cr = comp[y], qr = qa[y];
+ if (!cr || !qr) continue;
+ for (let x = 0; x < cr.length; x++) {
+ if (cr[x] === compId && (qr[x] | 0) > 0) { override = qr[x] | 0; break; }
+ }
+ }
+ }
+ const idx = (override > 0 ? override - 1 : compId - 1);
+ return ((idx % poolLen) + poolLen) % poolLen;
+ }
+
+ let quizBattleGateFlashAt = 0;
+ function quizBattleGateQuestionSnippetPlay(compId) {
+ if (!(compId > 0) || !quizBattleMcqPool.length) return '';
+ const q = quizBattleMcqPool[quizBattleQuestionIndexForCompPlay(compId, quizBattleMcqPool.length)];
+ if (!q || !q.text) return '';
+ const t = String(q.text).trim();
+ return t.length > 42 ? t.slice(0, 42) + '…' : t;
+ }
+
+ function quizBattleFlashGateBlocked(md, px, py, solvedSet) {
+ const need = quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet);
+ if (!need) return;
+ const now = performance.now();
+ if (now - quizBattleGateFlashAt < 1400) return;
+ quizBattleGateFlashAt = now;
+ const snip = quizBattleGateQuestionSnippetPlay(need);
+ flashQuizBattleFeedback(
+ snip
+ ? ('ตอบข้อ ' + need + ' ก่อนผ่าน: 「' + snip + '」')
+ : ('ตอบคำถามข้อ ' + need + ' ก่อนผ่านทางนี้'),
+ false
+ );
+ }
+
+ /** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */
+ function normalizeQuizBattlePathInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattlePathArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattlePathArea = grid;
+ }
+
+ function quizBattlePathModeActive(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false;
+ const g = md.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+ }
+
+ /** ทุกช่องที่สไปรต์กินพื้นที่ต้องอยู่บนเลน — กันหลุดไปพื้นเปิด (path=0) หรือทับกำแพง (objects=1) */
+ function quizBattleFootprintFullyOnPath(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ const g = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) — โซนรอก่อนลงเลน */
+ /** footprint แตะโซนจุดจบ (quizBattleFinishArea) ไหม — ใช้อนุญาตให้ยืน/เด้งไปจุดจบได้โดยไม่ถูกสแน็ปกลับเลน */
+ function quizBattleFootprintTouchesFinishPlay(md, px, py) {
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (fa[ty] && fa[ty][tx] === 1) return true;
+ }
+ const cx = Math.floor(px), cy = Math.floor(py);
+ return !!(fa[cy] && fa[cy][cx] === 1);
+ }
+
+ function quizBattlePositionAllowedPlay(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPath(md, px, py)) return true;
+ if (quizBattleFootprintTouchesFinishPlay(md, px, py)) return true; /* โซนจุดจบ — ยืนได้ */
+ const sa = md.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** สปอว์นในพื้นที่ฟ้า — เช็คแค่ไม่ทับกำแพง ไม่บังคับอยู่บนเลน */
+ function quizBattleSpawnWallClearPlay(md, nx, ny) {
+ if (!md || !md.objects) return false;
+ if (typeof nx !== 'number' || typeof ny !== 'number' || !Number.isFinite(nx) || !Number.isFinite(ny)) return false;
+ const w = md.width || 20, h = md.height || 15;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const k of quizTilesWallCollisionFootprintPlay(nx, ny)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** เดินทีละย่อย — เลนบาง 1 ช่อง + กล่องสไปรต์ใหญ่กว่าเท้า ยังเดินได้โดยไม่หลุดเลน */
+ function quizBattleTryStepMovePlay(fx, fy, tx, ty, canWalkFn) {
+ const SUB = 8;
+ let x = fx;
+ let y = fy;
+ for (let i = 1; i <= SUB; i++) {
+ const t = i / SUB;
+ const nx = fx + (tx - fx) * t;
+ const ny = fy + (ty - fy) * t;
+ if (canWalkFn(nx, ny, x, y)) {
+ x = nx;
+ y = ny;
+ }
+ }
+ return { x, y };
+ }
+
+ /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */
+ function quizBattleSnapTargetValidPlay(x, y) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ for (const k of quizTilesWallCollisionFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (quizBattlePathModeActive(mapData) && !quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ return true;
+ }
+
+ function snapPositionOntoQuizBattlePathIfNeeded(px, py) {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const g = mapData.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ /* footprint ใหญ่กว่าเลน: ลองจุดกลางช่อง — ต้องผ่านกล่องสไปรต์+path เหมือนก้อนบน (เดิมเช็คแค่ไม่ทับกำแพง → snap หลุดเลน) */
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = mapData.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+ }
+
+ /** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */
+ function enforceQuizBattleLaneOnMePlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ if (quizBattleSnapTargetValidPlay(me.x, me.y)) return;
+ const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = snapped.x;
+ me.y = snapped.y;
+ }
+
+ /** สแน็ป peer / บอทหลัง lerp — กันตัวละครล้ำกำแพงหรือหลุดเลนระหว่างเฟรม */
+ function enforceQuizBattleLaneOnPeersPlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ others.forEach((o) => {
+ if (!o) return;
+ if (quizBattleSnapTargetValidPlay(o.x, o.y)) return;
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = sn.x;
+ o.y = sn.y;
+ o.tx = sn.x;
+ o.ty = sn.y;
+ });
+ }
+
+ function hideQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ quizBattleChoiceLocked = false;
+ }
+
+ function flashQuizBattleFeedback(text, ok) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.textContent = text || '';
+ el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad');
+ el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad');
+ if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT);
+ window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800);
+ }
+
+ function getQuizBattleDomeCompIdsUnderFootprint(px, py) {
+ if (!mapData || !isQuizBattle()) return [];
+ const comp = mapData.quizBattleDomeComp;
+ const grid = mapData.quizBattleDomeArea;
+ if (!comp || !grid) return [];
+ const ids = [];
+ const seen = Object.create(null);
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const cid = comp[ty] && comp[ty][tx];
+ if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) {
+ seen[cid] = 1;
+ ids.push(cid);
+ }
+ }
+ return ids;
+ }
+
+ function getPrimaryQuizBattleDomeCompAt(px, py) {
+ const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py);
+ if (!arr.length) return null;
+ return Math.min.apply(null, arr);
+ }
+
+ function quizBattleFootprintOnDomePlay(md, px, py) {
+ const dome = md && md.quizBattleDomeArea;
+ if (!dome) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+ }
+
+ /** โดมใต้เท้า หรือช่องติดกัน (8 ทิศ) — ให้กด E / เด้งคำถามได้แม้ยืนข้างสถานี */
+ function getNearestQuizBattleDomeCompAt(px, py) {
+ const direct = getPrimaryQuizBattleDomeCompAt(px, py);
+ if (direct != null) return direct;
+ if (!mapData || !isQuizBattle()) return null;
+ const dome = mapData.quizBattleDomeArea;
+ const comp = mapData.quizBattleDomeComp;
+ if (!dome) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let best = null;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!dome[ny] || dome[ny][nx] !== 1) continue;
+ const cid = comp && comp[ny] && comp[ny][nx] ? comp[ny][nx] : 1;
+ if (best == null || cid < best) best = cid;
+ }
+ }
+ return best;
+ }
+
+ function showQuizBattleMcqModal(q) {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ const textEl = document.getElementById('quiz-battle-mcq-text');
+ if (!ov || !textEl || !q) return;
+ const titleEl = document.getElementById('quiz-battle-mcq-title');
+ if (titleEl) titleEl.textContent = 'QUESTION #' + (quizBattleModalCompId || '');
+ textEl.textContent = q.text || '';
+ const labels = ['A', 'B', 'C'];
+ for (let i = 0; i < 3; i++) {
+ const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!btn) continue;
+ const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : '';
+ const letterEl = btn.querySelector('.qbc-letter');
+ const txtEl = btn.querySelector('.qbc-text');
+ const markEl = btn.querySelector('.qbc-mark');
+ if (letterEl) letterEl.textContent = labels[i];
+ if (txtEl) txtEl.textContent = ch;
+ if (markEl) { markEl.removeAttribute('src'); markEl.alt = ''; }
+ btn.classList.remove('is-correct', 'is-wrong', 'is-locked');
+ btn.disabled = false;
+ }
+ quizBattleChoiceLocked = false;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+
+ function trySubmitQuizBattleChoice(idx) {
+ if (quizBattleChoiceLocked) return;
+ if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return;
+ const compId = quizBattleModalCompId;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false);
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ const qIdx = quizBattleQuestionIndexForCompPlay(compId, pool.length);
+ const q = pool[qIdx];
+ if (!q) return;
+ const ok = Number(q.correctIndex) === Number(idx);
+ /* ตอบครั้งเดียวจบ — ผิดก็ "ผ่าน" และจำว่าผิด (ไม่ให้ตอบซ้ำ) · log ใช้ทำ MY RESULT (O/X) + accuracy */
+ quizBattleAnswerLog.set(compId, {
+ qIndex: qIdx,
+ firstTry: ok,
+ correct: ok,
+ submittedIdx: idx,
+ q: q,
+ });
+ quizBattlePassedComps.add(compId); /* ผ่านโดมนี้แล้ว (ถูกหรือผิดก็ผ่าน — ปลดล็อกเส้นทาง) */
+ /* โชว์ผลถูก/ผิดบนตัวเลือกทันที (q-correct.png / q-wrong.png) แล้วล็อกกันกดซ้ำ */
+ quizBattleChoiceLocked = true;
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.querySelectorAll('.quiz-battle-choice').forEach((b) => { b.classList.add('is-locked'); b.disabled = true; });
+ const setMark = (i, kind) => {
+ const b = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!b) return;
+ b.classList.add(kind === 'correct' ? 'is-correct' : 'is-wrong');
+ const m = b.querySelector('.qbc-mark');
+ if (m) m.src = BASE + '/img/qb-ui/' + (kind === 'correct' ? 'q-correct.png' : 'q-wrong.png');
+ };
+ if (ok) {
+ setMark(idx, 'correct');
+ } else {
+ setMark(idx, 'wrong');
+ setMark(Number(q.correctIndex), 'correct'); /* เฉลยคำตอบที่ถูก */
+ }
+ }
+ if (ok) {
+ quizBattleAnsweredComps.add(compId);
+ if (typeof socket !== 'undefined' && socket && socket.connected) {
+ try { socket.emit('quizbattle-solve', { compId: compId }, function () {}); } catch (e) { /* ignore */ }
+ }
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ submitQuizBattleLeaderboardScore();
+ flashQuizBattleFeedback('ถูกต้อง · +1 · ทางไปข้างหน้าเปิดแล้ว', true);
+ } else {
+ flashQuizBattleFeedback('ตอบผิด — ผ่านได้เลย (บันทึกว่าผิด) ไม่ต้องตอบซ้ำ', false);
+ }
+ saveQuizBattleProgress();
+ /* ปิด popup แล้วเช็คจบ (ทั้งถูกและผิด) — ผิดหน่วงนานขึ้นให้เห็นเฉลยก่อน */
+ setTimeout(() => {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ const totalComps = quizBattleTotalCompsPlay();
+ if (!quizBattleCompletedShown && totalComps > 0 && quizBattlePassedComps.size >= totalComps) {
+ quizBattleCompletedShown = true;
+ setTimeout(openQuizBattleCompleted, 280);
+ }
+ }, ok ? 950 : 1300);
+ }
+
+ /**
+ * ชื่อผู้เล่นสำหรับ leaderboard/ranking — ผูกกับชื่อที่ตั้งใน Main-Lobby
+ * (localStorage 'roomLobbyDisplayName' → 'playerName' ผ่าน readStoredDisplayName / jdReadDisplayName)
+ * ก่อน fallback ไป me.nickname / ?nick= เพื่อให้ตรงกับโปรไฟล์เดียวกันทุกหน้า
+ */
+ function quizBattlePlayerDisplayName() {
+ let stored = '';
+ try {
+ if (typeof window.jdReadDisplayName === 'function') {
+ const v = String(window.jdReadDisplayName() || '').trim();
+ if (v && v !== 'MONE') stored = v; /* 'MONE' = ค่า default เมื่อยังไม่ตั้งชื่อ → ไม่ถือว่าตั้งแล้ว */
+ }
+ } catch (e) { /* ignore */ }
+ if (!stored) stored = readStoredDisplayName();
+ const name = stored || (me && me.nickname) || nick || 'ผู้เล่น';
+ return String(name || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น';
+ }
+
+ /** ส่งคะแนน (จำนวนตอบถูก) ขึ้น global leaderboard ของห้อง/หมวด */
+ function submitQuizBattleLeaderboardScore() {
+ if (!isQuizBattle() || !socket || !socket.connected) return;
+ const roomNo = quizBattleRoomNoPlay();
+ if (roomNo <= 0) return;
+ const score = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ try {
+ socket.emit('qb-leaderboard-submit', {
+ roomId: 'qbroom' + roomNo,
+ name: quizBattlePlayerDisplayName(),
+ score: score,
+ characterId: me.characterId || getPlayCharacterId() || '',
+ });
+ } catch (e) { /* ignore */ }
+ quizBattleScoreSubmittedAt = Date.now();
+ }
+
+ /* ===== Quiz Battle: progress persistence (resume ต่อจากเดิม) ===== */
+ function quizBattleProgressKey() {
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ return 'qbProgress:' + roomId + ':' + quizBattlePlayerDisplayName();
+ }
+ function saveQuizBattleProgress() {
+ if (!isQuizBattle()) return;
+ try {
+ const passed = [];
+ quizBattleAnswerLog.forEach((v, c) => {
+ passed.push({ c: c, qi: v.qIndex, ft: v.firstTry ? 1 : 0, ok: v.correct ? 1 : 0, idx: v.submittedIdx });
+ });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ const data = {
+ passed: passed,
+ pos: (me && Number.isFinite(me.x) && Number.isFinite(me.y)) ? { x: me.x, y: me.y } : null,
+ elapsed: Math.max(0, Math.round(elapsed)),
+ ts: Date.now(),
+ };
+ localStorage.setItem(quizBattleProgressKey(), JSON.stringify(data));
+ } catch (e) { /* ignore */ }
+ }
+ function loadQuizBattleProgress() {
+ try {
+ const raw = localStorage.getItem(quizBattleProgressKey());
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) { return null; }
+ }
+ /** คืนสถานะที่เคยเล่น (โดมที่ผ่าน/คำตอบ/เวลา) + ตำแหน่งล่าสุด — เรียกใน setupPlayQuizBattleUi */
+ function restoreQuizBattleProgress() {
+ const data = loadQuizBattleProgress();
+ if (!data || !Array.isArray(data.passed)) return;
+ data.passed.forEach((p) => {
+ if (!p || !(p.c > 0)) return;
+ quizBattleAnswerLog.set(p.c, {
+ qIndex: (p.qi != null ? p.qi : 0),
+ firstTry: !!p.ft,
+ correct: !!p.ok,
+ submittedIdx: p.idx,
+ q: null, /* q ผูกใหม่จาก pool ตอนเปิด MY RESULT */
+ });
+ quizBattlePassedComps.add(p.c);
+ if (p.ok) quizBattleAnsweredComps.add(p.c);
+ });
+ /* เวลาเดินต่อ (offset session start ด้วยเวลาที่เคยใช้) */
+ if (data.elapsed > 0) {
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleSessionStartMs = nowMs - data.elapsed;
+ }
+ /* sync score HUD + leaderboard ให้ตรงกับที่เคยทำ */
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = quizBattleAnsweredComps.size;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ /* ย้ายตัวไปจุดเดิม (ถ้ายังเดินได้) แล้วบอกเซิร์ฟเวอร์ */
+ if (data.pos && Number.isFinite(data.pos.x) && Number.isFinite(data.pos.y)) {
+ const px = data.pos.x, py = data.pos.y;
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle() || !me) return;
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — อย่าเด้งกลับจุดเดิม */
+ if (typeof canWalkLikeLobby === 'function' && !canWalkLikeLobby(px, py)) return;
+ me.x = px; me.y = py;
+ if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ draw();
+ } catch (e) { /* ignore */ }
+ }, 350);
+ }
+ /* เข้ามาแล้วตอบครบทุกข้ออยู่แล้ว (resume) → ไปจุดจบเลย ไม่ต้องตอบใหม่ */
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle()) return;
+ const total = quizBattleTotalCompsPlay();
+ if (total > 0 && quizBattlePassedComps.size >= total) {
+ quizBattleCompletedShown = true;
+ moveToQuizBattleFinishPlay();
+ }
+ } catch (e) { /* ignore */ }
+ }, 600);
+ }
+
+ /* ===== Quiz Battle: MY RESULT + RANKING popups (Stage 2) ===== */
+ function qbEscHtml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function qbFmtDuration(ms) {
+ const s = Math.max(0, Math.round((ms || 0) / 1000));
+ const m = Math.floor(s / 60), ss = s % 60;
+ if (m >= 60) { const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; }
+ if (m > 0) return m + 'm ' + ss + 's';
+ return ss + ' s';
+ }
+ /** จำนวนโดม (สถานีคำถาม) ทั้งหมดของแมป = ค่า comp สูงสุดใน quizBattleDomeComp */
+ function quizBattleTotalCompsPlay() {
+ const md = mapData;
+ if (md && md.quizBattleDomeComp && Array.isArray(md.quizBattleDomeComp)) {
+ let mx = 0;
+ const dc = md.quizBattleDomeComp;
+ for (let y = 0; y < dc.length; y++) {
+ const row = dc[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { const v = row[x] | 0; if (v > mx) mx = v; }
+ }
+ if (mx > 0) return mx;
+ }
+ return quizBattleMcqPool.length || 0;
+ }
+ function qbSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; }
+ function qbAvatarSrcForCharacter(cid) {
+ if (!cid) return '';
+ try { ensurePlayLobbyIdleAvatarImage(cid); } catch (e) { /* ignore */ }
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) return src;
+ const im = getCharacterImg(cid, 'down');
+ return (im && im.src) ? im.src : '';
+ }
+
+ function openQuizBattleMyResult() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-myresult-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ const passed = quizBattlePassedComps ? quizBattlePassedComps.size : 0;
+ let firstCorrect = 0, attempted = 0;
+ quizBattleAnswerLog.forEach((v) => { attempted++; if (v.firstTry) firstCorrect++; });
+ const accPct = attempted ? Math.round((firstCorrect / attempted) * 100) : 0;
+ const progPct = total ? Math.round((passed / total) * 100) : 0;
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-mr-accuracy', accPct + '%');
+ qbSetText('qb-mr-accuracy-sub', '(' + firstCorrect + '/' + attempted + ')');
+ qbSetText('qb-mr-progress', progPct + '%');
+ qbSetText('qb-mr-progress-sub', '(' + passed + '/' + total + ')');
+ qbSetText('qb-mr-time', qbFmtDuration(elapsed));
+ const grid = document.getElementById('qb-mr-grid');
+ if (grid) {
+ const cells = Math.max(total, attempted, 0);
+ let html = '';
+ for (let c = 1; c <= cells; c++) {
+ const log = quizBattleAnswerLog.get(c);
+ let cls = 'qb-mr-cell', mark = '';
+ if (!log) { cls += ' is-unanswered'; }
+ else if (log.correct) { cls += ' is-correct'; mark = ' '; }
+ else { cls += ' is-wrong'; mark = ' '; }
+ html += '' + c + ' ' + mark + '
';
+ }
+ grid.innerHTML = html;
+ }
+ const det = document.getElementById('qb-mr-detail');
+ if (det) {
+ const entries = Array.from(quizBattleAnswerLog.entries()).sort((a, b) => a[0] - b[0]);
+ if (!entries.length) {
+ det.innerHTML = 'ยังไม่ได้ตอบคำถาม — เดินเข้าโดมแล้วกด E เพื่อเริ่ม
';
+ } else {
+ const letters = ['A', 'B', 'C'];
+ let html = '';
+ entries.forEach(function (pair) {
+ const cid = pair[0], log = pair[1];
+ const q = log.q || quizBattleMcqPool[log.qIndex] || {};
+ const choices = Array.isArray(q.choices) ? q.choices : [];
+ const markImg = log.correct ? 'q-correct.png' : 'q-wrong.png';
+ html += '';
+ html += '
ข้อ ' + cid + '
';
+ html += '
' + qbEscHtml(q.text || '') + '
';
+ choices.forEach(function (ch, i) {
+ let ocls = 'qb-mr-q-opt';
+ if (i === Number(q.correctIndex)) ocls += ' is-correct';
+ else if (i === Number(log.submittedIdx) && !log.correct) ocls += ' is-chosen-wrong';
+ html += '
' + letters[i] + '. ' + qbEscHtml(ch) + '
';
+ });
+ html += '
';
+ });
+ det.innerHTML = html;
+ }
+ det.scrollTop = 0;
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ function renderQuizBattleRanking(data) {
+ const top = (data && Array.isArray(data.top)) ? data.top : [];
+ const meRow = data && data.me ? data.me : null;
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) {
+ const order = [1, 0, 2]; /* แสดง 2-1-3 */
+ let html = '';
+ order.forEach(function (idx) {
+ const e = top[idx];
+ if (!e) return;
+ const rank = idx + 1;
+ const av = qbAvatarSrcForCharacter(e.characterId);
+ html += '';
+ html += '
' + (av ? '
' : '') + '
';
+ html += '
' + rank + '
';
+ html += '
' + qbEscHtml(e.name || '-') + '
';
+ html += '
' + (e.score | 0) + '
';
+ html += '
';
+ });
+ podium.innerHTML = html || 'ยังไม่มีคะแนน
';
+ }
+ const list = document.getElementById('qb-rank-list');
+ if (list) {
+ let html = '';
+ for (let i = 3; i < top.length && i < 50; i++) {
+ const e = top[i];
+ html += '' + (e.rank || (i + 1)) + ' '
+ + '' + qbEscHtml(e.name || '-') + ' '
+ + '' + (e.score | 0) + '
';
+ }
+ list.innerHTML = html;
+ }
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) {
+ if (meRow && (meRow.rank || 0) > 0) {
+ meBox.innerHTML = '' + (meRow.rank || '-') + ' '
+ + '' + qbEscHtml(meRow.name || 'คุณ') + ' (คุณ) '
+ + '' + (meRow.score | 0) + '
';
+ } else {
+ meBox.innerHTML = '';
+ }
+ }
+ }
+
+ function openQuizBattleRanking() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-ranking-popup');
+ if (!pop) return;
+ /* ส่งคะแนนล่าสุดก่อน เพื่อให้อันดับตัวเองเป็นปัจจุบัน */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ const list = document.getElementById('qb-rank-list');
+ if (list) list.innerHTML = 'กำลังโหลดอันดับ…
';
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) podium.innerHTML = '';
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) meBox.innerHTML = '';
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ renderQuizBattleRanking(resp || {});
+ });
+ } catch (e) {
+ if (list) list.innerHTML = 'โหลดอันดับไม่สำเร็จ
';
+ }
+ } else if (list) {
+ list.innerHTML = 'ยังไม่ได้เชื่อมต่อเซิร์ฟเวอร์
';
+ }
+ }
+
+ function closeQuizBattlePopup(which) {
+ let id = 'qb-myresult-popup';
+ if (which === 'ranking') id = 'qb-ranking-popup';
+ else if (which === 'completed') id = 'qb-completed-popup';
+ const pop = document.getElementById(id);
+ if (!pop) return;
+ pop.classList.add('is-hidden');
+ pop.setAttribute('aria-hidden', 'true');
+ }
+
+ /** popup "Completed" (popup-result.png) — สรุป Correct / Time Taken / My Rank เมื่อตอบครบทุกโดม */
+ /** สุ่มช่องในโซนจุดจบ (quizBattleFinishArea) เป็นพิกัด tile — ไม่มี = null (ให้ผู้เล่นกระจายตัว ไม่กองจุดเดียว) */
+ function quizBattleFinishRandomCellPlay() {
+ const md = mapData;
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return null;
+ const cells = [];
+ for (let y = 0; y < fa.length; y++) {
+ const row = fa[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { if (row[x] === 1) cells.push([x, y]); }
+ }
+ if (!cells.length) return null;
+ const pick = cells[Math.floor(Math.random() * cells.length)];
+ /* สุ่มตำแหน่งย่อยในช่องด้วย เพื่อไม่ให้ซ้อนกันเป๊ะแม้สุ่มได้ช่องเดียวกัน */
+ const jitter = () => 0.25 + Math.random() * 0.5;
+ return { x: pick[0] + jitter(), y: pick[1] + jitter() };
+ }
+ /** ตอบครบทุกข้อ → ย้ายผู้เล่นไปจุดจบที่ตั้งไว้ใน Editor (สุ่มช่อง) */
+ function moveToQuizBattleFinishPlay() {
+ const c = quizBattleFinishRandomCellPlay();
+ if (!c || !me) return;
+ quizBattleFinishLock = true; /* ล็อก: หลังจากนี้ห้าม echo/restore เด้งกลับ */
+ me.x = c.x; me.y = c.y;
+ playPath = [];
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+
+ function openQuizBattleCompleted() {
+ if (!isQuizBattle()) return;
+ moveToQuizBattleFinishPlay(); /* ย้ายไปจุดจบที่ตั้งไว้ (ถ้ามี) */
+ const pop = document.getElementById('qb-completed-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ let firstCorrect = 0;
+ quizBattleAnswerLog.forEach((v) => { if (v.firstTry) firstCorrect++; });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-cp-correct', firstCorrect + ' / ' + total);
+ qbSetText('qb-cp-time', qbFmtDuration(elapsed));
+ qbSetText('qb-cp-rank', '—');
+ /* ส่งคะแนนล่าสุด แล้วดึงอันดับมาแสดง My Rank */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ const rank = resp && resp.me && resp.me.rank ? ('#' + resp.me.rank) : '—';
+ qbSetText('qb-cp-rank', rank);
+ });
+ } catch (e) { /* ignore */ }
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ /** หา comp โดมที่ตัวละครเหยียบอยู่ — เช็คหลายจุด + ช่องติดโดม ให้เด้งง่ายเมื่อถึงสถานี */
+ function quizBattleDomeAtMe() {
+ if (!isQuizBattle()) return null;
+ const pts = [[me.x, me.y], [me.x, me.y + 0.45], [me.x, me.y - 0.25], [me.x + 0.35, me.y], [me.x - 0.35, me.y]];
+ for (let i = 0; i < pts.length; i++) {
+ const c = getNearestQuizBattleDomeCompAt(pts[i][0], pts[i][1]);
+ if (c != null) return c;
+ }
+ return getNearestQuizBattleDomeCompAt(me.x, me.y);
+ }
+
+ /** ZEP-style: เหยียบสถานี (โดม) ที่ยังไม่ตอบ → เปิดคำถามอัตโนมัติ (ครั้งเดียวต่อการเข้า · เดินออกแล้วกลับมาใหม่ถึงเด้งอีก) */
+ function maybeAutoOpenQuizBattle() {
+ if (!isQuizBattle() || myId == null) return;
+ const compNow = quizBattleDomeAtMe();
+ 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 (quizBattlePassedComps.has(compNow)) return;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — บันทึกใน Admin → Quiz Battle แล้วรีเฟรช', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compNow, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compNow;
+ showQuizBattleMcqModal(q);
+ }
+
+ function qbRoundRect(ctx, x, y, w, h, r) {
+ r = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+ /** หีบบนสถานีคำถาม — ตอบถูก=เขียว+ติ๊ก / ตอบผิด(ผ่านแล้ว)=แดง+กากบาท / ยังไม่ตอบ=ล็อก(น้ำตาล+กุญแจ) + เลขสถานี */
+ function drawQuizBattleChest(ctx, cx, cy, size, done, num, wrong) {
+ const w = size * 0.84, h = size * 0.64;
+ const x0 = cx - w / 2, y0 = cy - h / 2;
+ ctx.save();
+ ctx.shadowColor = done ? (wrong ? 'rgba(248,113,128,0.95)' : 'rgba(52,211,153,0.95)') : 'rgba(255,200,70,0.95)';
+ ctx.shadowBlur = Math.max(4, size * 0.3);
+ ctx.fillStyle = done ? (wrong ? '#8a3340' : '#3f7d57') : '#8a5a2b';
+ qbRoundRect(ctx, x0, y0 + h * 0.34, w, h * 0.66, size * 0.08); ctx.fill();
+ ctx.fillStyle = done ? (wrong ? '#b3455a' : '#57a875') : '#a86f33';
+ qbRoundRect(ctx, x0, y0, w, h * 0.44, size * 0.1); ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = done ? (wrong ? '#ffd6dc' : '#d6f5e3') : '#f1c66a';
+ ctx.fillRect(x0, y0 + h * 0.30, w, h * 0.10);
+ ctx.fillRect(cx - size * 0.045, y0, size * 0.09, h);
+ if (done && wrong) {
+ ctx.strokeStyle = '#fff0f2'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.12, cy - size * 0.10); ctx.lineTo(cx + size * 0.12, cy + size * 0.12);
+ ctx.moveTo(cx + size * 0.12, cy - size * 0.10); ctx.lineTo(cx - size * 0.12, cy + size * 0.12);
+ ctx.stroke();
+ } else if (done) {
+ ctx.strokeStyle = '#eafff3'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.13, cy + size * 0.04);
+ ctx.lineTo(cx - size * 0.02, cy + size * 0.15);
+ ctx.lineTo(cx + size * 0.17, cy - size * 0.12);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = '#f1c66a'; ctx.lineWidth = Math.max(2, size * 0.05);
+ ctx.beginPath(); ctx.arc(cx, cy + h * 0.04, size * 0.085, Math.PI, 0); ctx.stroke();
+ ctx.fillStyle = '#f1c66a';
+ qbRoundRect(ctx, cx - size * 0.085, cy + h * 0.04, size * 0.17, size * 0.13, size * 0.03); ctx.fill();
+ }
+ if (num) {
+ ctx.fillStyle = 'rgba(8,12,28,0.82)';
+ ctx.beginPath(); ctx.arc(cx, y0 - size * 0.14, size * 0.17, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold ' + Math.max(9, size * 0.23) + 'px Kanit, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(String(num), cx, y0 - size * 0.14);
+ ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ }
+ ctx.restore();
+ }
+
+ function tryOpenQuizBattleFromKey() {
+ if (!isQuizBattle() || !mapData || myId == null) return;
+ const compId = quizBattleDomeAtMe();
+ if (compId == null) {
+ flashQuizBattleFeedback('เข้าใกล้สถานีคำถาม (หีบ) แล้วกด E', false);
+ return;
+ }
+ if (quizBattlePassedComps.has(compId)) {
+ flashQuizBattleFeedback('โดมนี้ผ่านแล้ว', false);
+ return;
+ }
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compId, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compId;
+ showQuizBattleMcqModal(q);
+ }
+
+ function pushSanitizedBattleMcqFromPayload(s) {
+ if (!s || !Array.isArray(s.battleQuizMcq)) return 0;
+ /* กรองเฉพาะคลังคำถามของหัวข้อห้องนี้ (mapData.quizBattleCategoryId เช่น topic1..topic10) — ไม่ระบุ = ใช้ทั้งหมด */
+ const roomCat = (mapData && mapData.quizBattleCategoryId) ? String(mapData.quizBattleCategoryId).trim() : '';
+ let n = 0;
+ for (let i = 0; i < s.battleQuizMcq.length; i++) {
+ const raw = s.battleQuizMcq[i];
+ if (!raw || !String(raw.text || '').trim()) continue;
+ if (roomCat && String(raw.categoryId || '').trim() !== roomCat) continue;
+ const ch = Array.isArray(raw.choices) ? raw.choices : [];
+ if (ch.length !== 3) continue;
+ const a = String(ch[0] || '').trim();
+ const b = String(ch[1] || '').trim();
+ const c = String(ch[2] || '').trim();
+ if (!a || !b || !c) continue;
+ let ci = Number(raw.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ quizBattleMcqPool.push({
+ text: String(raw.text).trim(),
+ choices: [a, b, c],
+ correctIndex: ci,
+ });
+ n++;
+ }
+ return n;
+ }
+
+ /**
+ * โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node)
+ */
+ async function loadQuizBattleMcqPool() {
+ quizBattleMcqPool = [];
+ const bust = '_=' + Date.now();
+ const tryUrls = [
+ BASE + '/api/quiz-settings?' + bust,
+ BASE + '/api-quiz-battle-mcq.php?' + bust,
+ ];
+ for (let u = 0; u < tryUrls.length; u++) {
+ try {
+ const r = await fetch(tryUrls[u], { cache: 'no-store' });
+ if (!r.ok) continue;
+ const s = await r.json();
+ if (pushSanitizedBattleMcqFromPayload(s) > 0) break;
+ } catch (e) { /* next url */ }
+ }
+ }
+
+ function resetQuizBattlePlayState() {
+ quizBattleMcqPool = [];
+ quizBattleAnsweredComps = new Set();
+ quizBattlePassedComps = new Set();
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+
+ function setupPlayQuizBattleUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ /* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */
+ if (ov) {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'Quiz Battle';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = quizBattlePathModeActive(mapData)
+ ? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง'
+ : 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม';
+ }
+ quizBattleAnsweredComps = new Set();
+ quizBattleAnswerLog = new Map();
+ quizBattlePassedComps = new Set();
+ quizBattleUserZoomMul = 1;
+ quizBattleSessionStartMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleModalCompId = null;
+ quizBattleMcqPool = [];
+ quizBattleCompletedShown = false;
+ quizBattleFinishLock = false;
+ hideQuizBattleMcqModal();
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ /* คืนสถานะที่เคยเล่น (โดมที่ผ่าน/ตำแหน่งล่าสุด) — เล่นต่อจากเดิมไม่ต้องตอบซ้ำ */
+ restoreQuizBattleProgress();
+ loadQuizBattleMcqPool().then(() => {
+ const q2 = document.getElementById('quiz-game-question');
+ if (!q2) return;
+ if (!quizBattleMcqPool.length) {
+ q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)';
+ } else {
+ const q0 = quizBattleMcqPool[0];
+ const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : '';
+ const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : '';
+ q2.textContent = snippet
+ ? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C')
+ : ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C');
+ }
+ const ov2 = document.getElementById('quiz-game-overlay');
+ if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden');
+ });
+ }
+
+ function normalizeJumpSurvivePlatformAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformArea = rows;
+ }
+
+ function normalizeJumpSurvivePlatformVariantAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea || [];
+ const src = md.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformVariantArea = rows;
+ }
+
+ function jumpSurvivePlatformVariantIndexAtPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa || !pa[ty] || pa[ty][tx] !== 1) return 0;
+ const va = md.jumpSurvivePlatformVariantArea;
+ let v = va && va[ty] ? Math.floor(Number(va[ty][tx])) : 1;
+ if (!Number.isFinite(v) || v < 1) v = 1;
+ if (v > 3) v = 3;
+ return v;
+ }
+
+ function normalizeJumpSurviveHazardAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurviveHazardArea = rows;
+ }
+
+ /** โซนตาย jump_survive — กริดคงที่ในโลก (ไม่เลื่อนกับแพลตฟอร์ม) · ช่องที่มีแพลตฟอร์มอยู่ด้วย = ไม่นับ hazard (ยืนบนแพลตฟอร์มชนะ) */
+ function jumpSurviveOverlapsHazardArea(md, left, top, right, bottom) {
+ const ha = md && md.jumpSurviveHazardArea;
+ if (!ha) return false;
+ const pa = md && md.jumpSurvivePlatformArea;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const y0 = Math.max(0, Math.floor(top / ts));
+ const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts));
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!ha[ty] || ha[ty][tx] !== 1) continue;
+ if (pa && pa[ty] && pa[ty][tx] === 1) continue;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function normalizeShooterSpawnSlotsInPlay(md) {
+ if (!md || (md.gameType !== 'space_shooter' && md.gameType !== 'jump_survive')) return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.shooterSpawnSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.shooterSpawnSlots = rows;
+ }
+
+ function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.shooterSpawnSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.shooterSpawnSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function buildSpaceShooterParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function applySpaceShooterSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const n = refs.length || 1;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = (idx % 6) + 1;
+ const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ cx = ((idx + 1) / (n + 1)) * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
+ }
+ cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
+ cy = clampSpaceShooterWorldCy(cy, mh);
+ ent.spaceShooterCx = cx;
+ ent.spaceShooterCy = cy;
+ ent.spaceShooterSlot = slot;
+ if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
+ ent.x = cx / ts - cw * 0.5;
+ ent.y = cy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'up';
+ });
+ }
+
+ function spaceShooterTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.spaceShooterTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playSpaceShooterMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 90;
+ }
+
+ function spaceShooterRemainingSecPlay() {
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null;
+ const lim = spaceShooterTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function endSpaceShooterTimeUp() {
+ if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay()) {
+ endSpaceShooterMissionRound('time_up');
+ return;
+ }
+ spaceShooterGameEnded = true;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
+ msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function syncSpaceShooterFootFromShipCenter(ent) {
+ if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.spaceShooterCx / ts - cw * 0.5;
+ ent.y = ent.spaceShooterCy / ts - ch * 0.92;
+ }
+
+ /** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
+ function clampSpaceShooterWorldCy(cy, mhPx) {
+ if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
+ const edge = 20;
+ const lo = mhPx * 0.5;
+ const hi = Math.max(lo + 4, mhPx - edge);
+ return Math.max(lo, Math.min(hi, cy));
+ }
+
+ const SPACE_SHOOTER_ASTEROID_INTERVAL_MIN = 200;
+ /** ms ระหว่างเกิดอุกาบาต — แมปทับถ้าตั้ง ≥200 */
+ function spaceShooterAsteroidSpawnIntervalMsPlay() {
+ const mapI = Number(mapData && mapData.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(mapI) && mapI >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) {
+ return Math.min(10000, Math.floor(mapI));
+ }
+ const g = Number(playSpaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(g) && g >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) return Math.min(10000, Math.floor(g));
+ return 1040;
+ }
+
+ function stepSpaceShooterPreviewBots(dt) {
+ if (spaceShooterGameEnded || !playBotsEnabled() || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const ts = tileSize;
+ const mw = (mapData.width || 20) * ts;
+ const mh = (mapData.height || 15) * ts;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.spaceShooterCx == null || o.spaceShooterEliminated) return;
+ let nearest = null, nd = 1e9;
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ if (!a) continue;
+ const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
+ if (d < nd) { nd = d; nearest = a; }
+ }
+ let vx = 0;
+ if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
+ else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
+ let vy = 0;
+ if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
+ vy = nearest.y > o.spaceShooterCy ? 1 : -1;
+ } else if (!nearest) {
+ vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
+ }
+ const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ o.spaceShooterCx = Math.max(padX, Math.min(mw - padX, o.spaceShooterCx + vx * spd * dt));
+ o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
+ syncSpaceShooterFootFromShipCenter(o);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
+ o.spaceShooterBotFireCd -= dt;
+ if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
+ o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
+ spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
+ }
+ });
+ }
+
+ function spaceShooterTickFrame() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const now = performance.now();
+ const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
+ spaceShooterLastTickMs = now;
+ tickPlayScrollBg(dt);
+ spaceShooterTickAsteroidExplosions(dt);
+
+ if (spaceShooterGameEnded) {
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+ return;
+ }
+ const remSec = spaceShooterRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endSpaceShooterTimeUp();
+ return;
+ }
+
+ const interval = spaceShooterAsteroidSpawnIntervalMsPlay();
+ spaceShooterSpawnAccMs += dt * 1000;
+ while (spaceShooterSpawnAccMs >= interval) {
+ spaceShooterSpawnAccMs -= interval;
+ const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp0 = maxHp;
+ spaceShooterAsteroids.push({
+ x: 36 + Math.random() * (mw - 72),
+ y: -45 - Math.random() * 90,
+ r: spaceShooterAsteroidRadiusFromHpPlay(hp0),
+ vy: 58 + Math.random() * 62,
+ maxHp: maxHp,
+ hp: hp0,
+ });
+ }
+
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
+ o.spaceShooterCx = (o.x + cw * 0.5) * ts;
+ o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
+ });
+
+ let vx = 0;
+ let vy = 0;
+ if (!isChatFocused()) {
+ if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
+ }
+ const moveSpd = 280;
+ const canPilotMe = !(isSpaceShooterMissionUiMapPlay() && me.spaceShooterEliminated);
+ if (canPilotMe) {
+ if (me.spaceShooterCx != null) {
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ me.spaceShooterCx = Math.max(padX, Math.min(mw - padX, me.spaceShooterCx + vx * moveSpd * dt));
+ }
+ if (me.spaceShooterCy != null) {
+ me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
+ }
+ syncSpaceShooterFootFromShipCenter(me);
+ }
+
+ spaceShooterFireCd -= dt;
+ const autoFireOk = canPilotMe && !isChatFocused();
+ if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
+ spaceShooterFireCd = 0.21;
+ spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
+ }
+
+ stepSpaceShooterPreviewBots(dt);
+
+ for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
+ const b = spaceShooterBullets[i];
+ b.y += b.vy * dt;
+ if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
+ }
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ a.y += a.vy * dt;
+ if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
+ }
+
+ spaceShooterMissionResolveShipAsteroidHitsPlay();
+
+ for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
+ const b = spaceShooterBullets[bi];
+ let hit = -1;
+ for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
+ const a = spaceShooterAsteroids[ai];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const rr = (a.r + 7) * (a.r + 7);
+ if (dx * dx + dy * dy <= rr) { hit = ai; break; }
+ }
+ if (hit >= 0) {
+ const a = spaceShooterAsteroids[hit];
+ const mh = Math.max(1, Math.floor(Number(a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1);
+ spaceShooterBullets.splice(bi, 1);
+ const add = 5;
+ if (b.ownerId === myId) {
+ me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ }
+ }
+ if (a.hp <= 0) {
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(hit, 1);
+ }
+ /** ขนาดคงที่จนแตก — ไม่ย่อ r ตาม HP ที่เหลือ (สลับสปรายต์ผ่าน spaceShooterAsteroidLiveSpriteIndexPlay) */
+ }
+ }
+
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ /* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+
+ me.direction = 'up';
+ me.isWalking = vx !== 0 || vy !== 0;
+ const tEmit = Date.now();
+ if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
+ spaceShooterLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
+ });
+ }
+ }
+
+ function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isSpaceShooter()) return;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
+
+ const astUrls = playSpaceShooterAsteroidSpriteUrls;
+
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ const [sx, sy] = worldToScreen(a.x, a.y);
+ const sr = Math.max(6, a.r * zDraw);
+ const si = spaceShooterAsteroidLiveSpriteIndexPlay(a);
+ let liveUrl = (astUrls && astUrls[si]) ? String(astUrls[si]).trim() : '';
+ if (!liveUrl && si > 0 && astUrls && astUrls[0]) liveUrl = String(astUrls[0]).trim();
+ const liveRec = liveUrl ? ensureGauntletAssetImage(liveUrl) : null;
+ const liveAstImg = liveRec && liveRec.ready && liveRec.img && liveRec.img.naturalWidth > 0 ? liveRec.img : null;
+ if (liveAstImg) {
+ const iw = liveAstImg.naturalWidth;
+ const ih = liveAstImg.naturalHeight;
+ const diam = sr * 2.15;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(liveAstImg, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ } else {
+ const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
+ grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
+ grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
+ grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
+ ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
+ ctx.shadowBlur = sr * 0.45;
+ ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ }
+
+ if (astUrls && astUrls.length >= 2) {
+ for (let ei = 0; ei < spaceShooterAsteroidExplosions.length; ei++) {
+ const ex = spaceShooterAsteroidExplosions[ei];
+ const u = astUrls[ex.fi + 1];
+ if (!u) continue;
+ const rec = ensureGauntletAssetImage(u);
+ const img = rec && rec.ready && rec.img && rec.img.naturalWidth > 0 ? rec.img : null;
+ if (!img) continue;
+ const [sx, sy] = worldToScreen(ex.x, ex.y);
+ const sr = Math.max(6, ex.r * zDraw);
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ const diam = sr * 2.4;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(img, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ }
+ }
+
+ for (let i = 0; i < spaceShooterBullets.length; i++) {
+ const b = spaceShooterBullets[i];
+ for (let k = 0; k < 4; k++) {
+ const t = k / 4;
+ const yy = b.y + t * 22;
+ const [bx, by] = worldToScreen(b.x, yy);
+ ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ const shipBody = spaceShooterShipBodyScreenPxPlay(zDraw);
+ const bodyW = shipBody.bodyW;
+ const bodyH = shipBody.bodyH;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
+ const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
+ const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated);
+ if (elimShip) return;
+ const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1)));
+ const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
+ const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null;
+ const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null;
+ let shipDrawW = bodyW * 2.2;
+ let shipDrawH = bodyH * 2.2;
+ if (shipImg) {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const iw = shipImg.naturalWidth;
+ const ih = shipImg.naturalHeight;
+ const maxW = bodyW * 2.4;
+ const maxH = bodyH * 2.4;
+ const sc = Math.min(maxW / iw, maxH / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ shipDrawW = dw;
+ shipDrawH = dh;
+ ctx.drawImage(shipImg, -dw * 0.5, -dh * 0.5, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ ctx.fillStyle = col;
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
+ ctx.lineWidth = Math.max(1.2, zDraw);
+ ctx.beginPath();
+ ctx.moveTo(0, -bodyH * 0.55);
+ ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
+ ctx.lineTo(0, bodyH * 0.22);
+ ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
+ ctx.lineTo(0, bodyH * 0.72);
+ ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ const hits = Math.max(0, Math.min(3, Number(ent.spaceShooterHits) || 0));
+ if (hits > 0 && playSpaceShooterShipDamageOverlayUrls && playSpaceShooterShipDamageOverlayUrls.length) {
+ const oUrl = (playSpaceShooterShipDamageOverlayUrls[hits - 1] || '').trim();
+ if (oUrl) {
+ const oRec = ensureGauntletAssetImage(oUrl);
+ const oImg = oRec && oRec.ready && oRec.img && oRec.img.naturalWidth > 0 ? oRec.img : null;
+ if (oImg) {
+ ctx.save();
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const ow = oImg.naturalWidth;
+ const oh = oImg.naturalHeight;
+ const scO = Math.min(shipDrawW / ow, shipDrawH / oh);
+ const odw = ow * scO;
+ const odh = oh * scO;
+ ctx.drawImage(oImg, sx - odw * 0.5, sy - odh * 0.5, odw, odh);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ const name = (ent.nickname || '').slice(0, 10);
+ if (name) {
+ ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
+ ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(name, sx, sy - bodyH * 0.62);
+ ctx.fillText(name, sx, sy - bodyH * 0.62);
+ ctx.textAlign = 'left';
+ }
+ });
+
+ for (let i = 0; i < spaceShooterPopups.length; i++) {
+ const p = spaceShooterPopups[i];
+ if (wallNow >= p.until) continue;
+ const [px, py] = worldToScreen(p.x, p.y);
+ const dur = 700;
+ const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
+ ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#ffe066';
+ ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(p.text || '+5', px, py - age * 18);
+ ctx.fillText(p.text || '+5', px, py - age * 18);
+ ctx.restore();
+ }
+ }
+
+ const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
+
+ function normalizeBalloonBossPlayerSlotsInPlay(md) {
+ if (!md || md.gameType !== 'balloon_boss') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.balloonBossPlayerSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.balloonBossPlayerSlots = rows;
+ }
+
+ function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.balloonBossPlayerSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.balloonBossPlayerSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function getBalloonBossBossWorldCenterPlay(md, ts) {
+ const bx = md && md.balloonBossBossSpawn;
+ if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
+ return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
+ }
+ const w = md.width || 20, h = md.height || 15;
+ return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
+ }
+
+ /**
+ * จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ)
+ * เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง)
+ */
+ function getBalloonBossBossLiveCenterPlay(md, ts) {
+ if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
+ const base = getBalloonBossBossWorldCenterPlay(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const tSec = balloonBossSessionStartMs > 0
+ ? (performance.now() - balloonBossSessionStartMs) / 1000
+ : performance.now() / 1000;
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return clampBalloonBossWorld(cx, cy, mw, mh);
+ }
+
+ /** จุดโลกประมาณกลางกระจุกลูกโป่ง (ใช้ hit จากกระสุนบอส) — ไม่ใช่จุดเท้า */
+ function balloonBossBalloonClusterWorldPlay(ent, bossCx, bossCy) {
+ if (!ent || ent.balloonBossCx == null) return { cx: 0, cy: 0 };
+ const dx = ent.balloonBossCx - bossCx;
+ const dy = ent.balloonBossCy - bossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const ox = (dx / L) * 22;
+ const oy = (dy / L) * 12;
+ return {
+ cx: ent.balloonBossCx + ox,
+ cy: ent.balloonBossCy - 52 + oy * 0.35,
+ };
+ }
+
+ /** ผู้เล่นหลักเด้งกับวงชนบอส (บอสเป็นวงนิ่งใน world) */
+ function balloonBossPlayerBossElasticPlay(meEnt, bossCx, bossCy, bossCollideR, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rB = Math.max(30, bossCollideR * 0.9);
+ const dx = meEnt.balloonBossCx - bossCx;
+ const dy = meEnt.balloonBossCy - bossCy;
+ const dist = Math.hypot(dx, dy) || 1e-6;
+ const minD = rMe + rB;
+ if (dist >= minD) return;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.58;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ const cl = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl.cx;
+ meEnt.balloonBossCy = cl.cy;
+ }
+
+ /** ผู้เล่นหลักเด้งกับคนอื่น (บอทขยับรับแรงปะทะเล็กน้อย) */
+ function balloonBossPlayerElasticCollisionsPlay(meEnt, aliveRefs, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rO = 44;
+ const minD = rMe + rO - 2;
+ for (let ii = 0; ii < aliveRefs.length; ii++) {
+ const entry = aliveRefs[ii];
+ if (!entry || entry.ref === meEnt) continue;
+ const o = entry.ref;
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) continue;
+ const dx = meEnt.balloonBossCx - o.balloonBossCx;
+ const dy = meEnt.balloonBossCy - o.balloonBossCy;
+ const dist = Math.hypot(dx, dy);
+ if (!dist || dist >= minD) continue;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.52;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ if (isPreviewBotId(entry.id)) {
+ const pushO = (minD - dist) * 0.48;
+ o.balloonBossCx -= nx * pushO;
+ o.balloonBossCy -= ny * pushO;
+ }
+ }
+ const cl2 = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl2.cx;
+ meEnt.balloonBossCy = cl2.cy;
+ }
+
+ /** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */
+ function getBalloonBossOccupiedSlotNumbersPlay(md) {
+ if (!md || !md.balloonBossPlayerSlots) return [];
+ const g = md.balloonBossPlayerSlots;
+ const w = md.width || 20, h = md.height || 15;
+ const seen = new Set();
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const v = g[y] && g[y][x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n));
+ }
+ }
+ return Array.from(seen).sort((a, b) => a - b);
+ }
+
+ function buildBalloonBossParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function balloonBossBalloonsStartPlay() {
+ const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
+ /** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */
+ return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3));
+ }
+
+ function balloonBossMaxHpPlay() {
+ const n = Number(mapData && mapData.balloonBossMaxHp);
+ return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
+ }
+
+ function balloonBossFireDelayMsPlay() {
+ const n = Number(mapData && mapData.balloonBossFireDelayMs);
+ return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
+ }
+
+ /** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
+ function balloonBossShipAccelPlay() {
+ const n = Number(mapData && mapData.balloonBossShipAccel);
+ return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
+ }
+
+ /** ความเร็วสูงสุดยาน (px/s) */
+ function balloonBossShipMaxSpeedPlay() {
+ const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
+ return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
+ }
+
+ /** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
+ function balloonBossShipDragPlay() {
+ const n = Number(mapData && mapData.balloonBossShipDrag);
+ return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
+ }
+
+ function balloonBossTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.balloonBossTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playBalloonBossMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 120;
+ }
+
+ function balloonBossRemainingSecPlay() {
+ const lim = balloonBossTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim;
+ if (!balloonBossSessionStartMs) return lim;
+ const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function balloonBossTeamDamagePlay() {
+ let s = Math.max(0, me.balloonBossScore | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
+ return s;
+ }
+
+ /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */
+ function balloonBossTeamBossDamagePlay() {
+ let s = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); });
+ return s;
+ }
+
+ function syncBalloonBossFootFromCenter(ent) {
+ if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ }
+
+ function clampBalloonBossWorld(cx, cy, mw, mh) {
+ const e = 22;
+ return {
+ cx: Math.max(e, Math.min(mw - e, cx)),
+ cy: Math.max(e, Math.min(mh - e, cy)),
+ };
+ }
+
+ function applyBalloonBossSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const n = refs.length || 1;
+ const bStart = balloonBossBalloonsStartPlay();
+ const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData);
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((idx % 6) + 1);
+ const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ const t = (idx + 1) / (n + 1);
+ cx = t * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
+ }
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ ent.balloonBossSkinSlot = slot;
+ ent.balloonBossCx = cl.cx;
+ ent.balloonBossCy = cl.cy;
+ if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
+ if (typeof ent.balloonBossBossDmg !== 'number' || !Number.isFinite(ent.balloonBossBossDmg)) ent.balloonBossBossDmg = 0;
+ /** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */
+ ent.balloonBossBalloons = bStart;
+ if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
+ ent.balloonBossVelX = 0;
+ ent.balloonBossVelY = 0;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'down';
+ if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) {
+ ent.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ });
+ }
+
+ function endBalloonBossGame(reason) {
+ if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
+ balloonBossGameEnded = true;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossScorePopups = [];
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyMegaVirusMissionPanelImages();
+ beginBalloonBossMegaVirusResultFlashSequenceThenGcm(balloonBossBuildMissionPayloadPlay(reason), reason);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
+ }
+ msgEl.textContent = reason === 'victory'
+ ? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากคะแนนการยิงโดน'
+ : 'หมดเวลา — อันดับจากคะแนนการยิงโดน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)} pts`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'balloon_boss') return;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
+ const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
+ const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
+ const spd = 48;
+ let nx = o.balloonBossCx + wob * spd * dt;
+ let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
+ const cl = clampBalloonBossWorld(nx, ny, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ syncBalloonBossFootFromCenter(o);
+ o.direction = 'down';
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
+ else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
+ if (o.balloonBossBotNextFire <= 0) {
+ o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const dx = bossCx - o.balloonBossCx;
+ const dy = bossCy - o.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const bspd = 500 + Math.random() * 90;
+ balloonBossPendingShots.push({
+ releaseAt: performance.now() + delayMs,
+ sx: o.balloonBossCx, sy: o.balloonBossCy,
+ vx: (dx / L) * bspd,
+ vy: (dy / L) * bspd,
+ ownerId: bid,
+ });
+ }
+ });
+ }
+
+ function balloonBossTickFrame() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (!o || o.balloonBossEliminated) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
+ const cx = (o.x + cw * 0.5) * ts;
+ const cy = (o.y + ch * 0.92) * ts;
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ });
+ const now = performance.now();
+ const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
+ balloonBossLastTickMs = now;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const bossR = Math.max(36, ts * 1.15);
+ const maxHp = balloonBossMaxHpPlay();
+ const teamBossDmg = balloonBossTeamBossDamagePlay();
+
+ if (me.balloonBossEliminated) {
+ me.balloonBossVelX = 0;
+ me.balloonBossVelY = 0;
+ }
+ if (balloonBossGameEnded) {
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+ return;
+ }
+ const remSec = balloonBossRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endBalloonBossGame('time');
+ return;
+ }
+ if (teamBossDmg >= maxHp) {
+ endBalloonBossGame('victory');
+ return;
+ }
+ const allRefsEarly = buildBalloonBossParticipantRefsPlay();
+ if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) {
+ endBalloonBossGame('all_dead');
+ return;
+ }
+
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+
+ const aliveRefs = allRefsEarly.filter((e) => e.ref && !e.ref.balloonBossEliminated);
+
+ for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
+ const pnd = balloonBossPendingShots[pi];
+ if (now >= pnd.releaseAt) {
+ balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
+ balloonBossPendingShots.splice(pi, 1);
+ }
+ }
+
+ const bossFireEvery = 1.42;
+ balloonBossBossFireAcc += dt;
+ while (balloonBossBossFireAcc >= bossFireEvery) {
+ balloonBossBossFireAcc -= bossFireEvery;
+ if (aliveRefs.length) {
+ const ang = Math.random() * Math.PI * 2;
+ const spd = 88 + Math.random() * 72;
+ balloonBossBossBullets.push({
+ x: bossC.cx, y: bossC.cy,
+ vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
+ gy: 48 + Math.random() * 56,
+ });
+ }
+ }
+
+ function applyBalloonBossHitToEntity(ent) {
+ if (!ent || ent.balloonBossEliminated) return;
+ if (ent.balloonBossHitIframe > now) return;
+ ent.balloonBossHitIframe = now + 520;
+ ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
+ if (ent.balloonBossBalloons <= 0) {
+ ent.balloonBossEliminated = true;
+ ent.balloonBossBalloons = 0;
+ }
+ if (ent === me && socket) {
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossBalloons: me.balloonBossBalloons,
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ });
+ }
+ }
+
+ for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
+ const b = balloonBossBossBullets[bi];
+ const gy = typeof b.gy === 'number' && Number.isFinite(b.gy) ? b.gy : 62;
+ b.vy += gy * dt;
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
+ balloonBossBossBullets.splice(bi, 1);
+ continue;
+ }
+ let hit = false;
+ aliveRefs.forEach((entry) => {
+ if (hit) return;
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) return;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const dx = b.x - cc.cx, dy = b.y - cc.cy;
+ if (dx * dx + dy * dy <= (36 * 36)) {
+ hit = true;
+ applyBalloonBossHitToEntity(ent);
+ }
+ });
+ if (hit) balloonBossBossBullets.splice(bi, 1);
+ }
+
+ for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
+ const b = balloonBossPlayerBullets[i];
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
+ balloonBossPlayerBullets.splice(i, 1);
+ continue;
+ }
+ let pHit = false;
+ for (let ji = 0; ji < aliveRefs.length; ji++) {
+ if (pHit) break;
+ const entry = aliveRefs[ji];
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) continue;
+ if (String(entry.id) === String(b.ownerId)) continue;
+ const canBalloonHit = isPreviewBotId(entry.id) || ent === me;
+ if (!canBalloonHit) continue;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const pdx = b.x - cc.cx, pdy = b.y - cc.cy;
+ if (pdx * pdx + pdy * pdy <= (34 * 34)) {
+ applyBalloonBossHitToEntity(ent);
+ balloonBossPlayerBullets.splice(i, 1);
+ pHit = true;
+ }
+ }
+ if (pHit) continue;
+ const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
+ if (dx * dx + dy * dy <= bossR * bossR) {
+ balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
+ balloonBossScorePopups.push({
+ ownerId: b.ownerId,
+ x: b.x,
+ y: b.y,
+ t: 0.85,
+ tMax: 0.85,
+ });
+ const addScore = 10;
+ const addBossDmg = 2;
+ if (b.ownerId === myId) {
+ me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore);
+ me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg);
+ if (socket && myId != null) {
+ balloonBossLastMoveEmit = Date.now();
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore);
+ o.balloonBossBossDmg = Math.max(0, (o.balloonBossBossDmg | 0) + addBossDmg);
+ }
+ }
+ balloonBossPlayerBullets.splice(i, 1);
+ }
+ }
+
+ const tWind = performance.now() * 0.001;
+ const gustAx = Math.sin(tWind * 0.36 + 1.15) * 62 + Math.sin(tWind * 1.05) * 24;
+ const gustAy = Math.cos(tWind * 0.39 + 0.28) * 58 + Math.cos(tWind * 0.93) * 22;
+ const maxSpd = Math.min(228, balloonBossShipMaxSpeedPlay() * 1.32);
+ const dragPerSec = balloonBossShipDragPlay() * 0.4;
+ if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
+ if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
+ let nvx = me.balloonBossVelX;
+ let nvy = me.balloonBossVelY;
+ nvx += gustAx * dt;
+ nvy += gustAy * dt;
+ const drag = Math.min(1, dragPerSec * dt);
+ nvx *= (1 - drag);
+ nvy *= (1 - drag);
+ const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
+ if (curSpd > maxSpd) {
+ const s = maxSpd / curSpd;
+ nvx *= s;
+ nvy *= s;
+ }
+ me.balloonBossVelX = nvx;
+ me.balloonBossVelY = nvy;
+ if (!me.balloonBossEliminated && me.balloonBossCx != null) {
+ const ncx = me.balloonBossCx + nvx * dt;
+ const ncy = me.balloonBossCy + nvy * dt;
+ const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
+ if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX *= -0.88;
+ if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY *= -0.88;
+ me.balloonBossCx = cl.cx;
+ me.balloonBossCy = cl.cy;
+ }
+ syncBalloonBossFootFromCenter(me);
+ balloonBossPlayerElasticCollisionsPlay(me, aliveRefs, mw, mh);
+ balloonBossPlayerBossElasticPlay(me, bossC.cx, bossC.cy, bossR, mw, mh);
+ syncBalloonBossFootFromCenter(me);
+ /** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */
+ me.direction = 'down';
+ me.isWalking = Math.abs(me.balloonBossVelX) > 12 || Math.abs(me.balloonBossVelY) > 12;
+
+ /** วงลูกศรหมุนเองเรื่อย ๆ (คุมไม่ได้) — Mega Virus ยิงตามมุมลูกศร ณ ตอนกด Space */
+ aliveRefs.forEach((e) => {
+ const r = e.ref;
+ if (!r || r.balloonBossEliminated) return;
+ if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) {
+ r.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ r.balloonBossAimRad += 1.02 * dt;
+ });
+
+ balloonBossPlayerFireCd -= dt;
+ const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
+ if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
+ balloonBossPlayerFireCd = 0.35;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const bspd = 480;
+ let vx;
+ let vy;
+ if (isMegaVirusMissionShellMapPlay()) {
+ const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad))
+ ? me.balloonBossAimRad
+ : Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx);
+ vx = Math.cos(aim) * bspd;
+ vy = Math.sin(aim) * bspd;
+ } else {
+ const dx = bossC.cx - me.balloonBossCx;
+ const dy = bossC.cy - me.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ vx = (dx / L) * bspd;
+ vy = (dy / L) * bspd;
+ }
+ balloonBossPendingShots.push({
+ releaseAt: now + delayMs,
+ sx: me.balloonBossCx, sy: me.balloonBossCy,
+ vx,
+ vy,
+ ownerId: myId,
+ });
+ }
+
+ stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
+
+ const tEmit = Date.now();
+ if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
+ balloonBossLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ }
+
+ function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isBalloonBoss()) return;
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const maxHp = balloonBossMaxHpPlay();
+ const dmg = balloonBossTeamBossDamagePlay();
+ const hpLeft = Math.max(0, maxHp - dmg);
+ const bossR = Math.max(36, ts * 1.15);
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const leaderId = (() => {
+ let best = null, bs = -1;
+ refs.forEach((e) => {
+ if (!e.ref || e.ref.balloonBossEliminated) return;
+ const sc = Math.max(0, e.ref.balloonBossScore | 0);
+ if (sc > bs) { bs = sc; best = e.id; }
+ });
+ return best;
+ })();
+ const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || ''));
+ const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null;
+
+ for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
+ const fx = balloonBossHitFx[hi];
+ const [hx, hy] = worldToScreen(fx.x, fx.y);
+ const pulse = 1 + (fx.t || 0) * 3;
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
+ ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
+ ctx.lineWidth = 3 * zDraw;
+ ctx.beginPath();
+ ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ const score10PopUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/score+10.png');
+ const score10PopRec = score10PopUrl ? ensureGauntletAssetImage(score10PopUrl) : null;
+ for (let si = 0; si < balloonBossScorePopups.length; si++) {
+ const sp = balloonBossScorePopups[si];
+ let wx = sp.x;
+ let wy = sp.y;
+ if (sp.ownerId != null) {
+ const oid = sp.ownerId;
+ const ent = (myId != null && String(oid) === String(myId)) ? me : others.get(oid);
+ if (ent && ent.balloonBossCx != null && !ent.balloonBossEliminated) {
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const tMax = typeof sp.tMax === 'number' && sp.tMax > 0 ? sp.tMax : 0.95;
+ const rise = ((tMax - Math.max(0, sp.t || 0)) / tMax) * 58;
+ wx = cc.cx;
+ wy = cc.cy - 46 - rise;
+ }
+ }
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, Math.max(0, sp.t || 0) * 1.12);
+ if (sp.dmgTxt) {
+ const fpx = Math.max(11, 14 * zDraw);
+ ctx.font = `bold ${fpx}px ui-sans-serif, system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.62)';
+ ctx.lineWidth = Math.max(2, 2.4 * zDraw);
+ ctx.strokeText(String(sp.dmgTxt), sx, sy);
+ ctx.fillStyle = '#f8fafc';
+ ctx.fillText(String(sp.dmgTxt), sx, sy);
+ } else if (score10PopRec && score10PopRec.ready && score10PopRec.img && score10PopRec.img.naturalWidth > 0) {
+ const iw = score10PopRec.img.naturalWidth;
+ const ih = score10PopRec.img.naturalHeight;
+ const mh = Math.max(14, 20 * zDraw);
+ const sc = Math.min(1.35, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(score10PopRec.img, sx - dw / 2, sy - dh / 2, dw, dh);
+ }
+ ctx.restore();
+ }
+
+ const bossAlive = hpLeft > 0;
+ if (bossAlive) {
+ const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
+ ctx.save();
+ ctx.translate(bsx, bsy);
+ const limR = bossR * zDraw;
+ if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) {
+ const iw = bossImgRec.img.naturalWidth;
+ const ih = bossImgRec.img.naturalHeight;
+ const diam = limR * 2.1;
+ const scale = Math.min(diam / iw, diam / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.beginPath();
+ for (let k = 0; k < 6; k++) {
+ const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
+ const px = Math.cos(ang) * bossR * zDraw * 1.05;
+ const py = Math.sin(ang) * bossR * zDraw * 1.05;
+ if (k === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
+ ctx.fill();
+
+ const skullR = bossR * zDraw * 0.62;
+ const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
+ grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
+ grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(0, 0, skullR, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
+ ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
+ ctx.shadowBlur = 12 * zDraw;
+ ctx.beginPath();
+ ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ /** HUD บอส: แถบชิดเหนือหัวบอส; โลโก้ Artboard10 + [hp/max] เหนือแถบ — bbHudS ลดสเกลทั้งแถว (ขอให้เล็กลง ~ครึ่ง) */
+ const bbHudS = 0.5;
+ const fontPx = Math.max(8, 12.5 * zDraw * bbHudS);
+ ctx.font = `bold ${fontPx}px ui-monospace, monospace`;
+ ctx.textBaseline = 'middle';
+ const hpStr = '[ ' + hpLeft + ' / ' + maxHp + ' ]';
+ const hpW = ctx.measureText(hpStr).width;
+ const gapHp = 5 * zDraw * bbHudS;
+
+ const powBgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power-bg.png');
+ const powFillUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power.png');
+ const powBgRec = powBgUrl ? ensureGauntletAssetImage(powBgUrl) : null;
+ const powFillRec = powFillUrl ? ensureGauntletAssetImage(powFillUrl) : null;
+
+ /** ความสูงหลอด: ยึดความกว้างก่อน — คูณ bbHudS ให้สัดส่วนเทียบหัวบอสเล็กลง */
+ const barHMin = Math.max(9, 10.5 * zDraw) * bbHudS;
+ const barHMax = Math.max(17, 21 * zDraw) * bbHudS;
+
+ const titleImgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/Artboard%2010.png');
+ const titleImgRec = titleImgUrl ? ensureGauntletAssetImage(titleImgUrl) : null;
+ let imgW = 0;
+ let imgH = 0;
+ if (titleImgRec && titleImgRec.ready && titleImgRec.img && titleImgRec.img.naturalWidth > 0) {
+ const iw = titleImgRec.img.naturalWidth;
+ const ih = titleImgRec.img.naturalHeight;
+ let barHForTitle = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const biw = powBgRec.img.naturalWidth;
+ const bijh = powBgRec.img.naturalHeight;
+ const wEst = Math.max(limR * 2.12 * bbHudS, hpW + gapHp + iw * 0.35 * bbHudS);
+ barHForTitle = Math.min(barHMax, Math.max(barHMin, bijh * (wEst / biw)));
+ }
+ const capTitle = Math.min(17 * zDraw * bbHudS, Math.max(fontPx * 1.15, barHForTitle * 0.88));
+ const maxH = Math.max(fontPx * 1.12, 14 * zDraw * bbHudS, capTitle);
+ const sc = Math.min(1.0, maxH / ih);
+ imgW = iw * sc;
+ imgH = ih * sc;
+ }
+
+ let rowW = (imgW > 0 ? imgW + gapHp : 0) + hpW;
+ if (imgW <= 0) {
+ const legacyPre = 'MEGA VIRUS : ';
+ rowW = ctx.measureText(legacyPre).width + hpW;
+ }
+ const barPadRow = 8 * zDraw * bbHudS;
+ const barWTarget = Math.max(limR * 2.08 * bbHudS, rowW + barPadRow);
+
+ let barDw = barWTarget;
+ let barDh = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const iw = powBgRec.img.naturalWidth;
+ const ih = powBgRec.img.naturalHeight;
+ const naturalH = ih * (barWTarget / iw);
+ barDh = Math.min(barHMax, Math.max(barHMin, naturalH));
+ }
+
+ const gapBarBoss = 4 * zDraw * bbHudS;
+ const gapTitleBar = 5 * zDraw * bbHudS;
+ const barBottomEdge = -limR * 1.0 - gapBarBoss;
+ const barTopY = barBottomEdge - barDh;
+ const bx = -barDw / 2;
+ const by = barTopY;
+ const ratHp = Math.max(0, Math.min(1, hpLeft / maxHp));
+
+ const titleRowH = Math.max(imgH, fontPx * 1.12);
+ const labelY = barTopY - gapTitleBar - titleRowH * 0.5;
+
+ let xLeft = -(imgW + (imgW > 0 ? gapHp : 0) + hpW) / 2;
+ if (imgW > 0) {
+ ctx.drawImage(titleImgRec.img, xLeft, labelY - imgH / 2, imgW, imgH);
+ xLeft += imgW + gapHp;
+ } else {
+ ctx.textAlign = 'left';
+ ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
+ const legacyPre = 'MEGA VIRUS : ';
+ const pw = ctx.measureText(legacyPre).width;
+ xLeft = -(pw + hpW) / 2;
+ ctx.fillText(legacyPre, xLeft, labelY);
+ xLeft += pw;
+ }
+ ctx.textAlign = 'left';
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
+ ctx.lineWidth = Math.max(0.5, 1 * zDraw * bbHudS);
+ ctx.strokeText(hpStr, xLeft, labelY);
+ ctx.fillText(hpStr, xLeft, labelY);
+
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ ctx.drawImage(powBgRec.img, bx, by, barDw, barDh);
+ if (ratHp > 0 && powFillRec && powFillRec.ready && powFillRec.img && powFillRec.img.naturalWidth > 0) {
+ const fiw = powFillRec.img.naturalWidth;
+ const fih = powFillRec.img.naturalHeight;
+ const padIn = barDw * 0.04;
+ const innerW = barDw - padIn * 2;
+ const innerH = barDh - padIn * 1.2;
+ const fsc2 = Math.min(innerW / fiw, innerH / fih);
+ const fdw2 = fiw * fsc2;
+ const fdh2 = fih * fsc2;
+ const fx0 = bx + (barDw - fdw2) * 0.5;
+ const fy0 = by + (barDh - fdh2) * 0.5;
+ const srcW = fiw * ratHp;
+ ctx.drawImage(powFillRec.img, 0, 0, srcW, fih, fx0, fy0, fdw2 * ratHp, fdh2);
+ }
+ } else {
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
+ ctx.fillRect(bx, by, barDw, barDh);
+ ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
+ ctx.fillRect(bx, by, barDw * ratHp, barDh);
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.strokeRect(bx, by, barDw, barDh);
+ }
+ ctx.restore();
+ }
+
+ /** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */
+ function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) {
+ const lx = 0;
+ const ly = dh * 0.5;
+ const c = Math.cos(tiltRad);
+ const s = Math.sin(tiltRad);
+ return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c };
+ }
+
+ /** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
+ function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
+ const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
+ const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
+ const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
+ const n = Math.max(0, Math.min(8, count | 0));
+ const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
+ const sz = 1.5;
+ const tw = 22 * sz * z;
+ const th = 27 * sz * z;
+ const stackAnchor = ringRpx + 10 * z;
+ const tieX = sx;
+ const tieY = sy - ringRpx * 0.98;
+ const tiltStep = 0.5;
+ const spreadX = 5.4 * z;
+ const baseY = sy - stackAnchor - 1.5 * z;
+ const items = [];
+ for (let b = 0; b < n; b++) {
+ const rel = b - (n - 1) / 2;
+ const tilt = rel * tiltStep;
+ const bx = sx + rel * spreadX;
+ const by = baseY + Math.abs(rel) * 2.1 * z;
+ const bImg = bRec && bRec.img;
+ let dw = 0;
+ let dh = 0;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ dw = iw * sc;
+ dh = ih * sc;
+ } else {
+ const ry = 12 * sz * z;
+ dw = 10 * sz * z * 2;
+ dh = ry * 2;
+ }
+ const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt);
+ items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y });
+ }
+ if (n > 0) {
+ ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)';
+ ctx.lineWidth = Math.max(0.65, 0.78 * z);
+ ctx.lineCap = 'butt';
+ ctx.lineJoin = 'bevel';
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ ctx.beginPath();
+ ctx.moveTo(tieX, tieY);
+ ctx.lineTo(it.knotX, it.knotY);
+ ctx.stroke();
+ }
+ }
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ const bImg = it.bImg;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+ }
+
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ if (ent.balloonBossEliminated) return;
+ const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
+ const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
+ const z = zDraw;
+ const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6)
+ ? ent.balloonBossSkinSlot
+ : ((idx % 6) + 1);
+ const ringR = 25 * z;
+ /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
+ const bubbleCy = sy - ringR * 0.42;
+ /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
+ const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad))
+ ? ent.balloonBossAimRad
+ : ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2));
+ /** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
+ const faceDir = 'down';
+ const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
+ const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim();
+ const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ /** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */
+ const ringRec = fbRing
+ ? resolveBalloonBossBalloonSpriteRec('', fbRing)
+ : resolveBalloonBossBalloonSpriteRec(perRing, '');
+ ctx.save();
+ const ringImg = ringRec && ringRec.img;
+ const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0);
+ if (ringPainted) {
+ const iw = ringImg.naturalWidth;
+ const ih = ringImg.naturalHeight;
+ const maxBox = 2 * ringR * 1.14;
+ const sc = Math.min(maxBox / iw, maxBox / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ /** หางฟอง (Artboard +Y) หมุนตาม angRing — tail ชี้ทิศยิง */
+ ctx.save();
+ ctx.translate(sx, bubbleCy);
+ ctx.rotate(angRing - Math.PI / 2);
+ ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ }
+ if (!ringPainted) {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)';
+ ctx.lineWidth = Math.max(1.2, 1.6 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
+ ctx.lineWidth = Math.max(1, 1.1 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR);
+ const nm = String(ent.nickname || '').slice(0, 12);
+ ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
+ ctx.lineWidth = 3;
+ let label = nm;
+ if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
+ const labelY = bubbleCy + ringR + 8 * z;
+ ctx.strokeText(label, sx, labelY);
+ ctx.fillText(label, sx, labelY);
+ const charImg = resolvePlayDrawCharSource(
+ ent.characterId, faceDir, timeMs, !!ent.isWalking,
+ ent.playTint || resolvePlayTintForPeer(entry.id),
+ myId != null && entry.id != null && String(entry.id) === String(myId),
+ );
+ const size = 26 * z;
+ if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
+ const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
+ const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
+ const sc = Math.min(size / iw, size / ih, 1);
+ const dw = iw * sc, dh = ih * sc;
+ const charTop = bubbleCy - dh * 0.48;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh);
+ } else {
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)';
+ ctx.lineWidth = Math.max(1, 1.2 * z);
+ ctx.stroke();
+ }
+ ctx.restore();
+ });
+
+ const bulletCodeUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/bullet-code.png');
+ const bulletCodeRec = bulletCodeUrl ? ensureGauntletAssetImage(bulletCodeUrl) : null;
+ for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
+ const b = balloonBossPlayerBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.save();
+ ctx.translate(bx, by);
+ /** bullet-code.png หัวลูกศรชี้ขวา (+x) — หมุนตามทิศความเร็วโดยไม่บวก π/2 (ก่อนหน้านี้ดูเหมือนยิงเฉียง) */
+ ctx.rotate(Math.atan2(b.vy, b.vx));
+ if (bulletCodeRec && bulletCodeRec.ready && bulletCodeRec.img && bulletCodeRec.img.naturalWidth > 0) {
+ const iw = bulletCodeRec.img.naturalWidth;
+ const ih = bulletCodeRec.img.naturalHeight;
+ const mh = Math.max(10, 15 * zDraw);
+ const sc = Math.min(1.4, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(bulletCodeRec.img, -dw / 2, -dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(0, -8 * zDraw);
+ ctx.lineTo(-5 * zDraw, 6 * zDraw);
+ ctx.lineTo(5 * zDraw, 6 * zDraw);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
+ ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
+ ctx.textAlign = 'center';
+ ctx.fillText('01001', 0, 12 * zDraw);
+ }
+ ctx.restore();
+ }
+ for (let i = 0; i < balloonBossBossBullets.length; i++) {
+ const b = balloonBossBossBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
+ ctx.stroke();
+ }
+ }
+
+ function jumpSurviveCollectPlatformCells(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ const out = [];
+ if (!pa) return out;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (pa[y] && pa[y][x] === 1) out.push({ x, y });
+ }
+ }
+ return out;
+ }
+
+ /** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกล่างจะวนมาโผล่บน */
+ function jumpSurvivePlatformPeriodPx(md) {
+ return (md.height || 15) * tileSize;
+ }
+
+ /**
+ * ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
+ */
+ function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
+ const ts = tileSize;
+ const h = md.height || 15;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const rawTop = ty * ts + scrollPx;
+ const hintY = (jumpSurviveCamCenterY > 1)
+ ? jumpSurviveCamCenterY
+ : h * ts * 0.72;
+ const k = Math.round((hintY - rawTop - ts * 0.35) / period);
+ const platTop = rawTop + k * period;
+ const feetY = platTop;
+ const pxc = (tx + 0.5) * ts;
+ return {
+ x: pxc / ts - cw * 0.5,
+ y: feetY / ts - ch,
+ };
+ }
+
+ function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
+ return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
+ }
+
+ function jumpSurviveRandomStandGridPos(md) {
+ const cells = jumpSurviveCollectPlatformCells(md);
+ const sc = jumpSurvivePlatformScrollPx;
+ if (cells.length) {
+ const c = cells[Math.floor(Math.random() * cells.length)];
+ return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
+ }
+
+ function jumpSurviveTileHasPlatformCellPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ const h = md.height || (pa && pa.length) || 15;
+ const w = md.width || 20;
+ if (!pa || ty < 0 || ty >= h) return false;
+ const row = pa[ty];
+ if (!row || tx < 0 || tx >= w) return false;
+ return row[tx] === 1;
+ }
+
+ /** หาแพลตฟอร์มในคอลัมน์ tx ใกล้ ty — กันจุดเกิดช่องว่างแล้วลอย */
+ function jumpSurviveFindPlatformColumnPlay(md, tx, tyHint) {
+ const w = md.width || 20, h = md.height || 15;
+ const tcx = Math.max(0, Math.min(w - 1, Math.floor(tx)));
+ let ty = Math.max(0, Math.min(h - 1, Math.floor(tyHint)));
+ if (jumpSurviveTileHasPlatformCellPlay(md, tcx, ty)) return { x: tcx, y: ty };
+ for (let d = 1; d < h; d++) {
+ const yb = ty + d;
+ if (yb < h && jumpSurviveTileHasPlatformCellPlay(md, tcx, yb)) return { x: tcx, y: yb };
+ const ya = ty - d;
+ if (ya >= 0 && jumpSurviveTileHasPlatformCellPlay(md, tcx, ya)) return { x: tcx, y: ya };
+ }
+ return { x: tcx, y: ty };
+ }
+
+ /** จุดเกิดตามลำดับ join / P1–P6 → world ยืนบนแพลตฟอร์ม (ไม่ทับสุ่ม pool) */
+ function jumpSurviveSpawnWorldFromJoinOrderPlay(md, joinOrder) {
+ const sp = pickSpawnForJoinPlay(md, joinOrder | 0);
+ let tx = Math.floor(Number(sp.x));
+ let ty = Math.floor(Number(sp.y));
+ if (!Number.isFinite(tx) || !Number.isFinite(ty)) {
+ const fb = md.spawn || { x: 1, y: 1 };
+ tx = Math.floor(Number(fb.x)) || 1;
+ ty = Math.floor(Number(fb.y)) || 1;
+ }
+ const cell = jumpSurviveFindPlatformColumnPlay(md, tx, ty);
+ const sc = jumpSurvivePlatformScrollPx;
+ const p = jumpSurviveGridPosStandingOnPlatform(md, cell.x, cell.y, sc);
+ return {
+ x: p.x + (Math.random() - 0.5) * 0.05,
+ y: p.y + (Math.random() - 0.5) * 0.05,
+ };
+ }
+
+ /** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
+ /** Quiz Battle พรีวิว: บอท spawn ตามโหมดแมป — ผู้เล่นจริงใช้ตำแหน่งจาก server แล้ว snap เท่าที่จำเป็น */
+ function applyQuizBattlePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'quiz_battle' || !quizBattlePathModeActive(mapData)) return;
+
+ function stampQuizEnt(ent, joinOrd) {
+ if (!ent) return;
+ const jo = Number.isFinite(Number(joinOrd)) ? Math.max(0, Math.floor(Number(joinOrd))) : 0;
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ ent.tx = pos.x;
+ ent.ty = pos.y;
+ }
+
+ if (!onlyBots) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = sn.x;
+ me.y = sn.y;
+ me.tx = sn.x;
+ me.ty = sn.y;
+ }
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ if (!onlyBots) {
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : realIds.indexOf(rid);
+ stampQuizEnt(o, jo);
+ });
+ }
+ botIds.forEach((bid, bi) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : countPlayHumans() + bi;
+ stampQuizEnt(o, jo);
+ });
+ }
+
+ function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'jump_survive') return;
+
+ function stampJumpBotState(o) {
+ if (!o) return;
+ o.jumpSurviveVy = 0;
+ o.jumpSurviveOnGround = true;
+ o.jumpSurviveEliminated = false;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+
+ if (isJumpSurviveMissionUiMapPlay()) {
+ function snapEnt(ent) {
+ if (!ent) return;
+ const ordRaw = Number(ent.spawnJoinOrder);
+ const jo = Number.isFinite(ordRaw) ? Math.max(0, Math.floor(ordRaw)) : 0;
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ stampJumpBotState(ent);
+ }
+ if (!onlyBots) {
+ snapEnt(me);
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ realIds.forEach((rid) => snapEnt(others.get(rid)));
+ }
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((bid) => snapEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ return;
+ }
+
+ const cells = jumpSurviveCollectPlatformCells(mapData);
+ cells.sort((a, b) => b.y - a.y || a.x - b.x);
+ const sc = jumpSurvivePlatformScrollPx;
+ const pool = cells.length
+ ? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
+ : [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+
+ if (onlyBots) {
+ let idx = 1 + realIds.length;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ o.x = p.x + (Math.random() - 0.5) * 0.08;
+ o.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(o);
+ });
+ return;
+ }
+
+ let idx = 0;
+ function assignEnt(ent) {
+ if (!ent || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ ent.x = p.x + (Math.random() - 0.5) * 0.08;
+ ent.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(ent);
+ }
+ assignEnt(me);
+ realIds.forEach((rid) => assignEnt(others.get(rid)));
+ botIds.forEach((bid) => assignEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ }
+
+ /**
+ * ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
+ * ตายจาก: hazard ที่วาง, บีบระหว่าง solid+object wall, หรือตกออกนอกขอบโลกแมป (ไม่ใช้ขอบกล้อง)
+ * @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
+ */
+ function jumpSurvivePhysicsIntegrate(md, opts) {
+ const {
+ dt,
+ cw, ch,
+ pxc0, feetY0, vy0, wasOnGround,
+ jumpQueued,
+ moveLeft, moveRight,
+ } = opts;
+ const w = md.width || 20, h = md.height || 15;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const bh = ts * 0.9;
+ let pxc = pxc0;
+ let feetY = feetY0;
+ let vy = vy0;
+ let onGround = wasOnGround;
+
+ const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
+ const g = gFull * dt;
+ let jumpV0;
+ if (Number(md.jumpSurviveJumpImpulse) > 0) {
+ jumpV0 = -Number(md.jumpSurviveJumpImpulse);
+ } else {
+ const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
+ ? playJumpSurviveJumpHeightMult
+ : 1.5;
+ const charHpixels = ch * ts;
+ const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
+ jumpV0 = -Math.min(impulse, ts * 36);
+ }
+ let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
+ if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35;
+
+ if (jumpQueued && onGround) {
+ vy = jumpV0;
+ onGround = false;
+ }
+ vy += g;
+
+ let nx = pxc;
+ if (moveLeft) nx -= moveX;
+ if (moveRight) nx += moveX;
+ nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
+
+ const headY0 = feetY - bh;
+ const tryL = nx - bw;
+ const tryR = nx + bw;
+ if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) {
+ pxc = nx;
+ }
+
+ feetY += vy * dt;
+ let headTop = feetY - bh;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ if (vy > 0) {
+ const step = ts * 0.035;
+ const penetrationEst = Math.abs(vy) * dt + ts * 0.55;
+ const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6));
+ for (let s = 0; s < maxSteps; s++) {
+ feetY -= step;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ onGround = true;
+ break;
+ }
+ }
+ } else if (vy < 0) {
+ const stepUp = ts * 0.035;
+ const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55;
+ const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6));
+ for (let s = 0; s < maxStepsUp; s++) {
+ feetY += stepUp;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ break;
+ }
+ }
+ }
+ } else {
+ onGround = false;
+ }
+
+ headTop = feetY - bh;
+ const headBandBottom = headTop + bh * 0.42;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
+ jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ /* ไม่ใช้ขอบกล้อง (cam topKill/botKill) — ตายเฉพาะ hazard ที่วาง + ตกออกนอกโลกแมปจริง (ไม่ตายในอากาศระหว่างแพลตฟอร์ม) */
+ const mapHpx = h * ts;
+ if (feetY > mapHpx + ts * 8 || headTop < -ts * 32) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ return { pxc, feetY, vy, onGround, died: false };
+ }
+
+ function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ o.x = pxc / tileSize - cw * 0.5;
+ o.y = feetYpx / tileSize - ch;
+ }
+
+ function jumpSurviveInitRuntime() {
+ jumpSurviveLastTickMs = performance.now();
+ jumpSurviveSessionStartMs = performance.now();
+ jumpSurviveVy = 0;
+ jumpSurviveJumpQueued = false;
+ jumpSurviveOnGround = false;
+ jumpSurvivePlatformScrollPx = 0;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ jumpSurviveCamCenterX = (w * ts) * 0.5;
+ jumpSurviveCamCenterY = (h * ts) * 0.5;
+ } else {
+ jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
+ jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
+ }
+ }
+
+ function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w) return true;
+ if (ty < 0) continue;
+ /* แพลตฟอร์มวนแนวตั้ง (period = h*ts) — worldY เกิน h*ts เป็นปกติ ห้ามถือ ty>=h เป็น “ซีลพื้นแมป” ไม่งั้น solid+กำแพงข้าง = ตายคาแอร์ */
+ if (ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!pa[ty] || pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ /* ceil/floor กับช่วงสั้น ๆ อาจได้ kMin > kMax → ไม่ลอง k เลย แล้วตกทะลุแพลตฟอร์ม */
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** แถวแพลตฟอร์มบนสุด/ล่างสุดของแมป (มีช่องอย่างน้อยหนึ่งช่อง) — ใช้เป็นเพดาน/พื้นตาย */
+ function jumpSurvivePlatformEdgeKillRows(md) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa) return null;
+ const h = Math.min(pa.length, md.height || pa.length || 15);
+ const wM = md.width || 20;
+ let minTy = h;
+ let maxTy = -1;
+ for (let ty = 0; ty < h; ty++) {
+ const row = pa[ty];
+ if (!row) continue;
+ const wlim = Math.min(row.length || 0, wM);
+ let rowHas = false;
+ for (let tx = 0; tx < wlim; tx++) {
+ if (row[tx] === 1) {
+ rowHas = true;
+ break;
+ }
+ }
+ if (!rowHas) continue;
+ if (ty < minTy) minTy = ty;
+ if (ty > maxTy) maxTy = ty;
+ }
+ if (minTy > maxTy) return null;
+ if (minTy === maxTy) return null;
+ return { minTy, maxTy };
+ }
+
+ /** ชนแพลตฟอร์มแถวเพดานเท่านั้น (รวมเลื่อนแบบคาบ) = ตายทันที — ไม่ฆ่าที่แถวพื้นล่าง */
+ function jumpSurviveOverlapsEdgeKillPlatforms(md, left, top, right, bottom, scrollPx) {
+ const edges = jumpSurvivePlatformEdgeKillRows(md);
+ if (!edges) return false;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ const overlapsRowTy = function (ty) {
+ if (ty < 0 || ty >= h || !pa[ty]) return false;
+ for (let tx = x0; tx <= x1; tx++) {
+ if (pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ const zSlack = ts * 0.08;
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top - zSlack && platTop < bottom + zSlack && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ return false;
+ };
+ if (overlapsRowTy(edges.minTy)) return true;
+ return false;
+ }
+
+ function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /**
+ * ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย
+ * (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน)
+ */
+ function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ const ts = tileSize;
+ const footSlop = ts * 0.16;
+ const platformBottom = bottom - footSlop;
+ if (platformBottom <= top + ts * 0.05) return false;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
+ function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
+ jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
+ }
+
+ function jumpSurviveCameraTargetPx(md) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 };
+ }
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = (e) => (e.x + cw * 0.5) * ts;
+ const cy = (e) => (e.y + ch * 0.5) * ts;
+ if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
+ const aliveHumanIds = [...others.keys()].filter((id) => {
+ if (isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const aliveBotIds = [...others.keys()].filter((id) => {
+ if (!isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
+ if (!useIds.length) return { px: cx(me), py: cy(me) };
+ let sx = 0, sy = 0;
+ useIds.forEach((id) => {
+ const o = others.get(id);
+ if (!o) return;
+ sx += cx(o);
+ sy += cy(o);
+ });
+ const n = useIds.length;
+ return { px: sx / n, py: sy / n };
+ }
+
+ /** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */
+ function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) {
+ if (!wishDir) return true;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const probeX = pxc + wishDir * (bw + ts * 0.82);
+ const pad = ts * 0.24;
+ const left = probeX - pad;
+ const right = probeX + pad;
+ const top = feetY - ts * 0.45;
+ const bottom = feetY + ts * 8;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx);
+ }
+
+ function stepJumpSurvivePreviewBots(dt, halfViewH) {
+ if (!playBotsEnabled() || !mapData || !isJumpSurvive()) return;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ts = tileSize;
+ const wpx = (md.width || 20) * ts;
+ const centerPx = wpx * 0.5;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (o.jumpSurviveEliminated) return;
+ if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
+ if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
+ if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
+ if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
+ if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
+
+ let pxc = (o.x + cw * 0.5) * ts;
+ let feetY = (o.y + ch) * ts;
+
+ const camCy = jumpSurviveCamCenterY;
+ const mission = isJumpSurviveMissionUiMapPlay();
+ const lowDanger = feetY > camCy + halfViewH - ts * 8.5;
+
+ if (Date.now() >= o.jumpSurviveWishNext) {
+ const wMin = mission && lowDanger ? 180 : 380;
+ const wSpan = mission && lowDanger ? 480 : 700;
+ o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan);
+ const tier = o.botTier || 'avg';
+ if (mission && lowDanger && Math.random() < 0.55) {
+ o.jumpSurviveWishX = 0;
+ } else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
+ else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
+ else if (tier === 'sharp') {
+ const roam = mission ? 0.42 : 0.55;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ } else {
+ const roam = mission ? 0.28 : 0.4;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ }
+ }
+
+ let moveLeft = o.jumpSurviveWishX < 0;
+ let moveRight = o.jumpSurviveWishX > 0;
+ let hazardJump = false;
+ if (o.jumpSurviveOnGround) {
+ const sc = jumpSurvivePlatformScrollPx;
+ if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0;
+ hazardJump = true;
+ } else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0;
+ hazardJump = true;
+ }
+ moveLeft = o.jumpSurviveWishX < 0;
+ moveRight = o.jumpSurviveWishX > 0;
+ }
+
+ let jumpQ = false;
+ if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now());
+ if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
+ o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
+ const tier = o.botTier || 'avg';
+ let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
+ if (mission) jp += 0.12;
+ if (lowDanger) jp += 0.22;
+ jp = Math.min(0.9, jp);
+ if (hazardJump || Math.random() < jp) jumpQ = true;
+ }
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: o.jumpSurviveVy,
+ wasOnGround: o.jumpSurviveOnGround,
+ jumpQueued: jumpQ,
+ moveLeft,
+ moveRight,
+ });
+
+ if (r.died) {
+ o.jumpSurviveEliminated = true;
+ o.jumpSurviveVy = 0;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ o.botIsWalking = false;
+ return;
+ }
+ o.jumpSurviveVy = r.vy;
+ o.jumpSurviveOnGround = r.onGround;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (moveRight) o.direction = 'right';
+ else if (moveLeft) o.direction = 'left';
+ o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
+ });
+ }
+
+ function jumpSurviveTickFrame() {
+ if (!mapData || !isJumpSurvive()) return;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const remEnd = jumpSurviveRemainingSecMission();
+ if (remEnd !== null && remEnd <= 0) {
+ endJumpSurviveMissionRound('time_up');
+ return;
+ }
+ }
+ const md = mapData;
+ const ts = tileSize;
+ const now = performance.now();
+ const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
+ jumpSurviveLastTickMs = now;
+
+ let zJumpSurviveWorld = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul;
+ const halfViewH = canvas.height / (2 * zJumpSurviveWorld);
+ const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
+ jumpSurvivePlatformScrollPx += rise;
+
+ const camTgt = jumpSurviveCameraTargetPx(md);
+ const ck = Math.min(1, dt * 7);
+ jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
+ jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
+
+ if (jumpSurviveEliminated) {
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+ jumpSurviveMissionMaybeEarlyFinish();
+ return;
+ }
+
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let pxc = (me.x + cw * 0.5) * ts;
+ let feetY = (me.y + ch) * ts;
+
+ const jq = jumpSurviveJumpQueued;
+ jumpSurviveJumpQueued = false;
+
+ /* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */
+ const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0;
+ const moveLeftIn = !!(keys['ArrowLeft'] || keys['KeyA']) || joyX < -0.001;
+ const moveRightIn = !!(keys['ArrowRight'] || keys['KeyD']) || joyX > 0.001;
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: jumpSurviveVy,
+ wasOnGround: jumpSurviveOnGround,
+ jumpQueued: jq,
+ moveLeft: moveLeftIn,
+ moveRight: moveRightIn,
+ });
+
+ if (r.died) {
+ jumpSurviveEliminated = true;
+ jumpSurviveVy = 0;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
+ } else {
+ jumpSurviveVy = r.vy;
+ jumpSurviveOnGround = r.onGround;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ }
+
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+
+ if (!jumpSurviveEliminated) {
+ me.isWalking = Math.abs(jumpSurviveVy) > 40 || moveLeftIn || moveRightIn;
+ if (moveRightIn) me.direction = 'right';
+ else if (moveLeftIn) me.direction = 'left';
+
+ if (Date.now() - jumpSurviveLastEmitT > 80) {
+ jumpSurviveLastEmitT = Date.now();
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+
+ jumpSurviveMissionMaybeEarlyFinish();
+ }
+
+ function gridImageLibIdleUrlPlay(entry) {
+ if (entry == null) return '';
+ if (typeof entry === 'string') return entry;
+ if (typeof entry === 'object' && entry.idle) return String(entry.idle);
+ return '';
+ }
+ function gridImageLibHeldUrlPlay(entry) {
+ if (entry == null || typeof entry === 'string') return '';
+ if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
+ return '';
+ }
+
+ function normalizeGridImageCellsOnMap(md) {
+ if (!md) return;
+ const w = md.width || 20, h = md.height || 15;
+ if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
+ md.gridImageLibrary = md.gridImageLibrary
+ .map((raw) => {
+ if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null };
+ if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) {
+ let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null;
+ if (held && held === raw.idle) held = null;
+ return { idle: raw.idle, held };
+ }
+ return null;
+ })
+ .filter(Boolean);
+ const libLen = md.gridImageLibrary.length;
+ let placements = [];
+ if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
+ placements = md.gridImageSprites.map((raw) => {
+ const i = Math.max(0, Math.floor(Number(raw.i)));
+ const x = Math.floor(Number(raw.x));
+ const y = Math.floor(Number(raw.y));
+ const ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
+ const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
+ const rawB = raw.bindCarryOption;
+ const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ const base = { i, x, y, w: ww, h: hh };
+ if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn;
+ return base;
+ }).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
+ Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
+ s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ placements = placements.map((s) => {
+ let x = s.x, y = s.y, ww = s.w, hh = s.h;
+ if (x < 0) { ww += x; x = 0; }
+ if (y < 0) { hh += y; y = 0; }
+ if (x + ww > w) ww = w - x;
+ if (y + hh > h) hh = h - y;
+ const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
+ if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption;
+ return out;
+ }).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ } else {
+ const cells = md.gridImageCells;
+ if (Array.isArray(cells) && cells.length === h && libLen > 0) {
+ for (let yy = 0; yy < h; yy++) {
+ const row = cells[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] == null) continue;
+ const iv = parseInt(row[xx], 10);
+ if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
+ placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
+ }
+ }
+ }
+ }
+ md.gridImagePlacements = placements;
+ const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
+ placements.forEach((sp) => {
+ for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
+ for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
+ if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
+ }
+ }
+ });
+ md.gridImageCells = outCells;
+ }
+
+ function loadMapGridImages() {
+ mapGridImageImgs = [];
+ mapGridImageHeldImgs = [];
+ if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
+ mapData.gridImageLibrary.forEach((entry, i) => {
+ mapGridImageImgs[i] = null;
+ mapGridImageHeldImgs[i] = null;
+ const idle = gridImageLibIdleUrlPlay(entry);
+ if (!idle) return;
+ const im = new Image();
+ im.onload = () => { try { draw(); } catch (e) {} };
+ im.onerror = () => {};
+ im.src = idle;
+ mapGridImageImgs[i] = im;
+ const held = gridImageLibHeldUrlPlay(entry);
+ if (held && held !== idle) {
+ const hm = new Image();
+ hm.onload = () => { try { draw(); } catch (e) {} };
+ hm.onerror = () => {};
+ hm.src = held;
+ mapGridImageHeldImgs[i] = hm;
+ }
+ });
+ }
+
+ /** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */
+ function dominantQuizCarryOptionInSpriteRect(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null;
+ const g = md.quizCarryOptionArea;
+ const mw = md.width || 20, mh = md.height || 15;
+ const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0;
+ const counts = new Map();
+ function addOverlapRect(x0, y0, ww, hh) {
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ const v = row[xx];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue;
+ const idx = n - 1;
+ counts.set(idx, (counts.get(idx) || 0) + 1);
+ }
+ }
+ }
+ addOverlapRect(bx, by, bw, bh);
+ if (!counts.size) {
+ const pad = 1;
+ const x0 = Math.max(0, bx - pad);
+ const y0 = Math.max(0, by - pad);
+ const x1 = Math.min(mw, bx + bw + pad);
+ const y1 = Math.min(mh, by + bh + pad);
+ addOverlapRect(x0, y0, x1 - x0, y1 - y0);
+ }
+ if (!counts.size) return null;
+ let best = null;
+ let bestN = -1;
+ counts.forEach((n, k) => {
+ if (best == null || n > bestN || (n === bestN && k < best)) {
+ bestN = n;
+ best = k;
+ }
+ });
+ return best;
+ }
+
+ /** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */
+ function quizCarryOptionIndexForGridSprite(md, sp) {
+ if (!sp) return null;
+ const rawB = sp.bindCarryOption;
+ if (rawB != null && rawB !== '') {
+ const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1;
+ }
+ return dominantQuizCarryOptionInSpriteRect(md, sp);
+ }
+
+ /** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */
+ function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) return gh;
+ }
+ return null;
+ }
+
+ /** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */
+ function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gid = mapGridImageImgs[sp.i];
+ if (gid && gid.complete && gid.naturalWidth) return gid;
+ }
+ return null;
+ }
+
+ function applyMapAndStart(gameMapData, res) {
+ const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
+ const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
+ mapData = gameMapData;
+ playEmbedUserZoomMul = 1;
+ if (mapData.gameType !== 'quiz_carry') {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ }
+ {
+ const qMap = (playMapIdFromQuery || '').trim();
+ const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : '';
+ const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : '';
+ if (qMap) playSessionMapId = qMap;
+ else if (dataId) playSessionMapId = dataId;
+ else if (resMap) playSessionMapId = resMap;
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (res && res.hostId != null) playHostId = res.hostId;
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ if (prevWasSpaceShooter) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ spaceShooterMissionPhase = null;
+ spaceShooterAsteroidExplosions = [];
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccLeave = document.getElementById('gauntlet-crown-countdown');
+ if (gccLeave) gccLeave.classList.add('is-hidden');
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ if (prevWasBalloonBoss) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ }
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ if (!mapData.interactive) mapData.interactive = [];
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
+ if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
+ if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = [];
+ if (!mapData.quizQuestions) mapData.quizQuestions = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ normalizeQuizCarryLayersInPlay(mapData);
+ applyQuizCarryWalkSpeedFromSettingsObj(quizCarryJoinSettingsSnap || {});
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ }
+ tileSize = mapData.tileSize || 32;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ const plist = Array.isArray(res.peers) ? res.peers : [];
+ const myPeer = plist.find(p => p.id === myId);
+ const myPos = peerXYFromJoin(myPeer, mapData.spawn);
+ me.x = myPos.x;
+ me.y = myPos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
+ me.x = hubSnap.x;
+ me.y = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = pathSnap.x;
+ me.y = pathSnap.y;
+ }
+ me.direction = myPeer?.direction ?? 'down';
+ if (myPeer && myPeer.nickname) {
+ me.nickname = String(myPeer.nickname).trim() || me.nickname;
+ nick = me.nickname;
+ }
+ me.characterId = myPeer?.characterId || null;
+ meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ {
+ const sc0 = Number(myPeer && myPeer.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
+ me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated);
+ }
+ {
+ const sh0 = Number(myPeer && myPeer.spaceShooterScore);
+ me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
+ }
+ {
+ const bSt = balloonBossBalloonsStartPlay();
+ const bs0 = Number(myPeer && myPeer.balloonBossScore);
+ me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
+ const bd0 = Number(myPeer && myPeer.balloonBossBossDmg);
+ me.balloonBossBossDmg = Number.isFinite(bd0) ? Math.max(0, bd0) : 0;
+ const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
+ me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
+ me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
+ }
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes;
+ const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId);
+ me.playTint = playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin);
+ if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) {
+ try {
+ const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex);
+ const si = parsePlayLobbySkinIndex(myPeerJoin.lobbySkinToneIndex) || 1;
+ if (ti) {
+ localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({
+ themeRgb: PLAY_LOBBY_THEME_HEX[ti - 1],
+ skinRgb: PLAY_LOBBY_SKIN_HEX[si - 1],
+ }));
+ }
+ } catch (eTintStore) { /* ignore */ }
+ }
+ // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม
+ try {
+ preloadPlayCharacterLayers(me.characterId || getPlayCharacterId());
+ (Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); });
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ {
+ const jo = Number(myPeer && myPeer.spawnJoinOrder);
+ me.spawnJoinOrder = Number.isFinite(jo) ? Math.max(0, Math.floor(jo)) : Math.max(0, plist.findIndex((q) => q && q.id === myId));
+ }
+ others.clear();
+ plist.forEach(p => {
+ if (p.id !== myId) {
+ const pos = peerXYFromJoin(p, mapData.spawn);
+ let px = pos.x, py = pos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
+ px = hubSnap.x;
+ py = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ px = pathSnap.x;
+ py = pathSnap.y;
+ }
+ const joP = Number(p && p.spawnJoinOrder);
+ const spawnOrd = Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : Math.max(0, plist.findIndex((q) => q && q.id === p.id));
+ others.set(p.id, {
+ x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
+ spawnJoinOrder: spawnOrd,
+ playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p),
+ gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ gauntletEliminated: !!p.gauntletEliminated,
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(p.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(p.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!p.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ }
+ });
+ if (mapData.gameType === 'gauntlet') {
+ me.tx = me.x;
+ me.ty = me.y;
+ if (res.gauntletEndsAt != null) {
+ const geJoin = Number(res.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ } else {
+ me.tx = null;
+ me.ty = null;
+ gauntletEndsAtMs = null;
+ }
+ rebalancePreviewBots();
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ applyQuizBattlePreviewSpawnLayout(false);
+ try { socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (eSync) { /* ignore */ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (isStack()) {
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 50);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ playPath = [];
+ me.isWalking = false;
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 50);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ }
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 50);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR = document.getElementById('gauntlet-crown-countdown');
+ if (gccR) gccR.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRm = document.getElementById('gauntlet-crown-countdown');
+ if (gccRm) gccRm.classList.add('is-hidden');
+ if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld });
+ }
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) {
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRj = document.getElementById('gauntlet-crown-countdown');
+ if (gccRj) gccRj.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccRs = document.getElementById('gauntlet-crown-countdown');
+ if (gccRs) gccRs.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gccRq = document.getElementById('gauntlet-crown-countdown');
+ if (gccRq) gccRq.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const gccRst = document.getElementById('gauntlet-crown-countdown');
+ if (gccRst) gccRst.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hov) {
+ hov.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR2 = document.getElementById('gauntlet-crown-countdown');
+ if (gccR2) gccR2.classList.add('is-hidden');
+ teardownStackTowerMissionUiPlay();
+ }
+ resizeCanvas(); draw(); tick();
+ }
+
+ socket.on('connect', () => {
+ firstCharacterDefaultPromise.then(() => {
+ const joinPlayMapId = (params.get('map') || '').trim();
+ const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null)
+ || readStoredDisplayName()
+ || nick;
+ socket.emit('join-space', {
+ spaceId,
+ nickname: joinNick,
+ characterId: getPlayCharacterId(),
+ playMapId: joinPlayMapId || undefined,
+ playerKey: getJdPlayerKey(),
+ }, (res) => {
+ if (!res || !res.ok) {
+ const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
+ alert(errMsg);
+ const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
+ if (caseLocked) {
+ window.location.replace(buildRoomLobbyReturnHref());
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ return;
+ }
+ myId = socket.id;
+ if (specialQuizForceDebug) {
+ try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ }
+ }
+ if (__isTestModeOnPlay()) {
+ try { socket.emit('special-quiz-testmode', { on: true }); } catch (eT) { /* ignore */ }
+ }
+ let botSlots = parseInt(res.botSlotCount, 10);
+ if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
+ botSlots = 6 - res.maxPlayers;
+ }
+ if (botSlots > 0) {
+ detectiveCaseFillBots = true;
+ detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots));
+ }
+ if (isDetectiveMinigamePlay()) {
+ try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ }
+ }
+ if (res.quizSettingsSnap && typeof res.quizSettingsSnap === 'object') {
+ if (res.quizSettingsSnap.quizMapPanelTheme && typeof res.quizSettingsSnap.quizMapPanelTheme === 'object') {
+ setQuizMapPanelThemeFromApi({ quizMapPanelTheme: res.quizSettingsSnap.quizMapPanelTheme });
+ }
+ }
+ if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') {
+ quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap;
+ if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') {
+ setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme });
+ }
+ if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') {
+ setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme });
+ }
+ if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes });
+ } else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme });
+ }
+ const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(joinSc)) {
+ quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc));
+ }
+ }
+ if (res.specialQuizIcon) setSpecialQuizIconFromServer(res.specialQuizIcon);
+ if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
+ const q = [];
+ q.push('space=' + encodeURIComponent(spaceId));
+ q.push('nick=' + encodeURIComponent(nick));
+ const mid = params.get('map');
+ if (mid) q.push('map=' + encodeURIComponent(mid));
+ q.push('preview=1');
+ q.push('editorEmbed=1');
+ if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
+ window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
+ return;
+ }
+ const playMapId = params.get('map');
+ if (playMapId) {
+ fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId))
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data) applyMapAndStart(data, res);
+ else applyMapAndStart(res.mapData, res);
+ })
+ .catch(() => applyMapAndStart(res.mapData, res));
+ } else {
+ applyMapAndStart(res.mapData, res);
+ }
+ });
+ });
+ });
+
+ const LERP = 0.2;
+
+ function applyPlayDisplayNameUpdate(data) {
+ if (!data || !data.id) return;
+ const next = String(data.nickname || '').trim();
+ if (!next) return;
+ if (data.id === myId) {
+ me.nickname = next;
+ nick = next;
+ try {
+ localStorage.setItem('roomLobbyDisplayName', next);
+ localStorage.setItem('playerName', next);
+ } catch (e) { /* ignore */ }
+ if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') {
+ window.jdDisplayName.syncDom(next);
+ }
+ } else {
+ const o = others.get(data.id);
+ if (o) o.nickname = next;
+ }
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (typeof syncPlayCyberHud === 'function') syncPlayCyberHud();
+ }
+
+ socket.on('peer-display-name', applyPlayDisplayNameUpdate);
+
+ socket.on('lobby-tint-sync', applyPlayLobbyTintSync);
+
+ window.addEventListener('jd-display-name-changed', function (ev) {
+ const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : '';
+ if (!next || !myId) return;
+ applyPlayDisplayNameUpdate({ id: myId, nickname: next });
+ });
+
+ socket.on('user-joined', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ const x = Number(data.x);
+ const y = Number(data.y);
+ const px = Number.isFinite(x) ? x : 1;
+ const py = Number.isFinite(y) ? y : 1;
+ const joJ = Number(data && data.spawnJoinOrder);
+ const spawnOrdJ = Number.isFinite(joJ) ? Math.max(0, Math.floor(joJ)) : 0;
+ others.set(data.id, {
+ x: px, y: py, tx: px, ty: py,
+ direction: data.direction || 'down', nickname: data.nickname,
+ characterId: data.characterId ?? null,
+ spawnJoinOrder: spawnOrdJ,
+ playTint: playTintFromJoinPeer(data) || playTintForPreviewBotId(data.id) || resolvePlayTintForPeer(data.id, data),
+ gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(data.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(data.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!data.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('host-changed', (d) => {
+ if (d && d.hostId != null) playHostId = d.hostId;
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('quiz-carry-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ quizCarryLobbyReadyMap = { ...d.readyMap };
+ if (quizCarryPregameActive && isQuizCarry()) {
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+ });
+ socket.on('quiz-carry-lobby-started', () => {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ endQuizCarryEmbedPregameAndStart();
+ });
+ socket.on('gauntlet-crown-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ gauntletCrownLobbyReadyMap = { ...d.readyMap };
+ if (gauntletCrownPregamePhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ } else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ } else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ } else if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ } else if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ });
+ socket.on('gauntlet-crown-lobby-started', () => {
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ beginGauntletCrownCountdownThenRun();
+ });
+ socket.on('user-move', (data) => {
+ if (data && myId != null && data.id === myId) {
+ /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
+ if (mapData && mapData.gameType === 'gauntlet') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'stack') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'jump_survive') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'space_shooter') {
+ if (data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
+ }
+ return;
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) me.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
+ return;
+ }
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — เพิกเฉย echo ตำแหน่งของตัวเอง (กันเด้งกลับ) */
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) me.x = x;
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) me.y = y;
+ }
+ const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = s.x;
+ me.y = s.y;
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) {
+ me.x = x;
+ me.tx = me.x;
+ }
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) {
+ me.y = y;
+ me.ty = me.y;
+ }
+ }
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ const o = others.get(data.id);
+ if (o) {
+ if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px) && Number.isFinite(py)) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ o.tx = sn.x;
+ o.ty = sn.y;
+ o.x = sn.x;
+ o.y = sn.y;
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ }
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ if (mapData && isQuiz()) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px)) o.x = px;
+ if (Number.isFinite(py)) o.y = py;
+ }
+ }
+ o.direction = data.direction || o.direction;
+ if (data.characterId != null) o.characterId = data.characterId;
+ if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) o.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
+ }
+ }
+ });
+ socket.on('user-left', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ others.delete(data.id);
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('chat', (data) => {
+ const box = document.getElementById('chat-messages');
+ if (!box) return;
+ const div = document.createElement('div');
+ div.className = 'chat-msg';
+ div.textContent = (data.nickname || '') + ': ' + (data.text || '');
+ box.appendChild(div);
+ box.scrollTop = 1e9;
+ });
+
+ socket.on('quiz-phase', (p) => {
+ if (previewMode) return;
+ if (!p || !mapData || !isQuiz()) return;
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') {
+ quizQuestionMissionDeferredPhase = p;
+ return;
+ }
+ applyQuizPhaseFromServer(p);
+ });
+
+ socket.on('quiz-result', (r) => {
+ if (previewMode || !r) return;
+ if (r.scores) {
+ playLiveQuizScores = { ...r.scores };
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ if (isQuiz() && Array.isArray(r.results)) {
+ r.results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) {
+ playQuizEverWrong[String(row.id)] = true;
+ const o = others.get(row.id);
+ if (o) {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ }
+ }
+ });
+ }
+ if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ });
+
+ socket.on('quiz-player-state', (st) => {
+ if (previewMode) return;
+ if (!st) return;
+ playQuizPlayerLocal = {
+ cannotTrue: !!st.cannotTrue,
+ cannotFalse: !!st.cannotFalse,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
+ };
+ if (quizGhostConfineActivePlay()) warpMeIntoQuizGhostZonePlay();
+ });
+
+ socket.on('quiz-ended', () => {
+ if (previewMode && isQuiz()) return;
+ let questionMissionEnd = null;
+ if (isQuizQuestionMissionUiMapPlay()) {
+ questionMissionEnd = quizQuestionMissionBuildPayload();
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizPhaseLocal = null;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ }
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playLiveQuizScores = {};
+ hidePlayQuizHud();
+ if (questionMissionEnd) {
+ quizQuestionMissionPhase = 'ended';
+ quizQuestionMissionDeferredPhase = null;
+ applyQuizQuestionMissionPanelImages();
+ showGauntletCrownMissionOverlay(questionMissionEnd);
+ }
+ });
+
+ /* ===== Special Quiz ในเกม — socket events ===== */
+ socket.on('special-quiz-icon', (payload) => {
+ if (previewMode) return;
+ setSpecialQuizIconFromServer(payload);
+ });
+ socket.on('special-quiz-icon-clear', () => { clearSpecialQuizIcon(); });
+ socket.on('special-quiz-question', (q) => {
+ if (previewMode) return;
+ openSpecialQuizQuestion(q);
+ });
+ socket.on('special-quiz-answer-update', (d) => {
+ if (previewMode || !d || !specialQuizActiveQuestion) return;
+ markSpecialQuizMemberAnswered(d.id);
+ });
+ socket.on('special-quiz-result', (r) => {
+ if (previewMode) return;
+ handleSpecialQuizResult(r);
+ });
+ socket.on('special-quiz-ended', (data) => {
+ if (previewMode) return;
+ endSpecialQuiz(data);
+ });
+ socket.on('special-card-applied', (data) => {
+ if (previewMode || !data || !data.card) return;
+ applySpecialCardClient(data);
+ });
+
+ socket.on('gauntlet-sync', (data) => {
+ if (!data || typeof data !== 'object') return;
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyGauntletTimingFromServer(data);
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ applyGauntletTimingFromServer(data);
+ if (Array.isArray(data.obstacles)) {
+ gauntletObstacles = data.obstacles;
+ pushGauntletObsRenderFrame(data.obstacles);
+ }
+ if (!Array.isArray(data.players)) return;
+ data.players.forEach((p) => {
+ if (!p || p.id == null || myId == null) return;
+ const pid = String(p.id);
+ const mid = String(myId);
+ if (pid === mid) {
+ const prevSc = Math.max(0, Number(me.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) me.tx = px;
+ if (Number.isFinite(py)) me.ty = py;
+ if (p.direction) me.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ me.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(me);
+ } else {
+ const o = others.get(p.id) ?? others.get(pid);
+ if (o) {
+ const prevSc = Math.max(0, Number(o.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) o.tx = px;
+ if (Number.isFinite(py)) o.ty = py;
+ if (p.direction) o.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(o);
+ }
+ }
+ });
+ if (playBotsEnabled()) {
+ applyGauntletPreviewBotsAfterSync();
+ emitGauntletPreviewRowsToServer();
+ }
+ });
+
+ function showMinigameCoinsToast(mine) {
+ let el = document.getElementById('minigame-coins-toast');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'minigame-coins-toast';
+ el.style.cssText = 'position:fixed;left:50%;top:13%;transform:translateX(-50%);z-index:99999;padding:13px 26px;border-radius:14px;font-weight:700;text-align:center;pointer-events:none;box-shadow:0 6px 24px rgba(0,0,0,.45);transition:opacity .35s;opacity:0;font-size:20px;line-height:1.3;';
+ document.body.appendChild(el);
+ }
+ const survived = mine.survived !== false;
+ const coins = Math.max(0, Number(mine.coins) || 0);
+ if (survived && coins > 0) {
+ el.style.background = 'linear-gradient(135deg,#1b9e4b,#13c06a)';
+ el.style.color = '#fff';
+ el.innerHTML = '🪙 +' + coins + ' เหรียญคะแนนสะสม +' + coins + ' ';
+ } else if (survived) {
+ el.style.background = 'linear-gradient(135deg,#555,#777)';
+ el.style.color = '#fff';
+ el.textContent = 'รอบนี้ไม่ติดอันดับรางวัล';
+ } else {
+ el.style.background = 'linear-gradient(135deg,#a11,#d33)';
+ el.style.color = '#fff';
+ el.textContent = '💀 ตายแล้ว — ไม่ได้รับเหรียญ';
+ }
+ el.style.opacity = '1';
+ if (window.__miniCoinsToastT) clearTimeout(window.__miniCoinsToastT);
+ window.__miniCoinsToastT = setTimeout(() => { el.style.opacity = '0'; }, 4200);
+ }
+
+ socket.on('minigame-coins', (data) => {
+ try {
+ if (!data || !Array.isArray(data.results)) return;
+ const mine = data.results.find((r) => r.id === myId);
+ if (mine) showMinigameCoinsToast(mine);
+ } catch (e) { /* ignore */ }
+ });
+
+ socket.on('gauntlet-ended', (data) => {
+ cancelQuizCarryResultEndAfterTimeup();
+ gauntletEndsAtMs = null;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ gauntletCrownRunHeldRemote = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ if (data && data.crownMission) {
+ showGauntletCrownMissionOverlay(data.crownMission);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
+ }
+ msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
+ listEl.innerHTML = '';
+ const ranks = (data && data.rankings) || [];
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ });
+
+ socket.on('game-start', (ev) => {
+ if (!ev || !ev.mapId) return;
+ const urlMap = (params.get('map') || '').trim();
+ if (urlMap && ev.stayInRoomLobby && String(ev.mapId).trim() !== urlMap) {
+ return;
+ }
+ const applySnap = (md) => {
+ mapData = md;
+ playEmbedUserZoomMul = 1;
+ if (ev.mapId != null && String(ev.mapId).trim() !== '') {
+ playSessionMapId = String(ev.mapId).trim();
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (!isStackTowerMissionUiMapPlay()) {
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ spaceShooterMissionPhase = null;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ tileSize = mapData.tileSize || 32;
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
+ ev.peersSnap.forEach((p) => {
+ if (p.id != null && myId != null && String(p.id) === String(myId)) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { me.x = px; me.tx = px; }
+ if (Number.isFinite(py)) { me.y = py; me.ty = py; }
+ me.direction = p.direction || me.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ } else {
+ const o = others.get(p.id) ?? others.get(String(p.id));
+ if (o) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { o.x = px; o.tx = px; }
+ if (Number.isFinite(py)) { o.y = py; o.ty = py; }
+ o.direction = p.direction || o.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ }
+ }
+ });
+ } else {
+ meGauntletJumpTicks = 0;
+ meGauntletJumpVis = 0;
+ me.gauntletScore = 0;
+ me.gauntletEliminated = false;
+ others.forEach((o) => {
+ o.gauntletJumpTicks = 0;
+ o.gauntletJumpVis = 0;
+ o.gauntletScore = 0;
+ o.gauntletEliminated = false;
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ if (ev.gauntletEndsAt != null) {
+ const ge = Number(ev.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc0 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc0) gcc0.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ const hto2 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto2) hto2.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc1 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc1) gcc1.classList.add('is-hidden');
+ }
+ } else {
+ gauntletEndsAtMs = null;
+ const megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID;
+ const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay())
+ || (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay())
+ || (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay())
+ || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay())
+ || megaShellGs;
+ if (!skipHideCrownShell) {
+ const hto3 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto3) hto3.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc2 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc2) gcc2.classList.add('is-hidden');
+ }
+ if (mapData.gameType !== 'gauntlet') {
+ me.tx = null;
+ me.ty = null;
+ }
+ const gauntletOv = document.getElementById('gauntlet-ended-overlay');
+ if (gauntletOv) gauntletOv.classList.add('is-hidden');
+ const gcmOv = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmOv) gcmOv.classList.add('is-hidden');
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ rebalancePreviewBots();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 60);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 60);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) {
+ balloonBossSessionStartMs = 0;
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ })
+ .catch(() => {});
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccMv = document.getElementById('gauntlet-crown-countdown');
+ if (gccMv) gccMv.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ balloonBossSessionStartMs = performance.now();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ if (quizBattlePathModeActive(mapData)) {
+ const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = ms.x;
+ me.y = ms.y;
+ others.forEach((o) => {
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ });
+ }
+ }
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 60);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ resizeCanvas();
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(true);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(true);
+ }
+ if (playBotsEnabled() && mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ };
+ fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
+ .then((r) => (r.ok ? r.json() : null))
+ .then((md) => { if (md) applySnap(md); });
+ });
+
+ function resizeCanvas() {
+ if (mapData && isStackTowerMissionUiMapPlay() && canvas) {
+ canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W);
+ canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H);
+ /* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ syncQuizCarryEmbedCountdownLayout();
+ return;
+ }
+ const vw = window.innerWidth || document.documentElement.clientWidth || 800;
+ const vh = window.innerHeight || document.documentElement.clientHeight || 600;
+ const header = document.querySelector('.game-header');
+ const headerH = playHideTopChrome() ? 0 : (header && header.offsetHeight ? header.offsetHeight : 48);
+ const stage = document.getElementById('play-canvas-stage');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const parent = stage || (canvas && canvas.parentElement);
+ let cw = canvas.clientWidth || 0;
+ let ch = canvas.clientHeight || 0;
+ if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
+ if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
+ /* จอใหญ่ / iframe embed: client บางทียัง 0 ช้ากว่า layout — ใช้ offset เป็นทางเลือก */
+ if (parent) {
+ const ow = parent.offsetWidth || 0;
+ const oh = parent.offsetHeight || 0;
+ if (ow > 80) cw = Math.max(cw, ow);
+ if (oh > 80) ch = Math.max(ch, oh);
+ }
+ /* embed: stage อาจยัง 0 แต่ stack วัดได้แล้ว — อย่าให้ fallback เป็น vw/vh เต็มจอแล้ว CSS ย่อเข้า stage = ดูชิดมุม */
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ const sh = Math.max(stackEl.clientHeight || 0, stackEl.offsetHeight || 0);
+ if (sw > 80) cw = Math.max(cw, sw);
+ if (sh > 80) ch = Math.max(ch, sh);
+ }
+ if (ch < 120) ch = Math.max(240, vh - headerH - 6);
+ if (cw < 120) {
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ cw = sw > 80 ? Math.max(320, sw) : Math.max(320, vw);
+ } else cw = Math.max(320, vw);
+ }
+ canvas.width = Math.max(320, cw);
+ canvas.height = Math.max(240, ch);
+ syncQuizCarryEmbedCountdownLayout();
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) {
+ _quizTfScaleLast = 0;
+ updateQuizTfMockScale(hud);
+ }
+ }
+
+ canvas.addEventListener('click', (e) => {
+ if (!mapData || !isStack() || isChatFocused()) return;
+ tryHumanStackDrop();
+ });
+ (function setupPlayEditorEmbedViewZoom() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const wheelZoomHandler = (e) => {
+ if (!mapData) return;
+ const embedZoom = previewMode && editorEmbedReturn;
+ const qbZoom = isQuizBattle();
+ if (!embedZoom && !qbZoom) return;
+ if (isStackTowerEmbedZoomLockedPlay()) return;
+ if (isQuizQuestionMissionHudActivePlay()) return;
+ const t = e.target;
+ if (t && typeof t.closest === 'function') {
+ if (t.closest('button, input, textarea, select, a[href]')) return;
+ }
+ /* ล้อธรรม / trackpad pinch มักไม่มี ctrl — ให้ซูมได้; Ctrl/⌘+ล้อยังใช้ได้ */
+ e.preventDefault();
+ e.stopPropagation();
+ const raw = e.deltaMode === 1 ? e.deltaY * 14 : e.deltaMode === 2 ? e.deltaY * 320 : e.deltaY;
+ const k = Math.exp(-raw * 0.002);
+ if (qbZoom) {
+ quizBattleUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * k),
+ );
+ } else {
+ playEmbedUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * k),
+ );
+ }
+ draw();
+ };
+ stack.addEventListener('wheel', wheelZoomHandler, { passive: false, capture: true });
+ })();
+
+ /* กด/แตะไอคอนควิซพิเศษ — ทำงานทุกมินิเกม (กันเคสเดินไปแตะไม่ได้ เช่น space shooter) */
+ canvas.addEventListener('pointerdown', (e) => {
+ if (trySpecialQuizClickAt(e.clientX, e.clientY)) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }, true);
+
+ canvas.addEventListener('dblclick', (e) => {
+ if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) return;
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+ const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zHit = lastPlayZDrawForInput > 0 ? lastPlayZDrawForInput : zoom;
+ const gx = (sx - canvas.width / 2) / zHit + camX;
+ const gy = (sy - canvas.height / 2) / zHit + camY;
+ const tx = Math.floor(gx / tileSize);
+ const ty = Math.floor(gy / tileSize);
+ const mw = mapData.width || 20, mh = mapData.height || 15;
+ if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
+ if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
+ const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
+ if (path.length <= 1) return;
+ playPath = path.slice(1);
+ });
+
+ function getCharacterFootprintWH(md) {
+ if (!md) return { cw: 1, ch: 1 };
+ let cw = Math.floor(Number(md.characterCellsW));
+ let ch = Math.floor(Number(md.characterCellsH));
+ const hasW = Number.isFinite(cw) && cw >= 1;
+ const hasH = Number.isFinite(ch) && ch >= 1;
+ if (hasW && hasH) {
+ return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
+ }
+ const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
+ return { cw: leg, ch: leg };
+ }
+
+ /** Quiz Battle: กล่องชน/เส้นทางตามสไปรต์ที่วาดจริง (drawAvatar ใช้ cw×1.15, ch×1.35 จากเท้า) */
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15;
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35;
+
+ function quizBattleSpriteBoundsTilesPlay(md, px, py) {
+ const s = new Set();
+ if (!md || md.gameType !== 'quiz_battle') return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = px + cw * 0.5;
+ const feetY = py + ch;
+ const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H;
+ const bottom = feetY;
+ if (left < 0 || right > w || top < 0 || bottom > h) return s;
+ const minTx = Math.floor(left);
+ const maxTx = Math.min(w - 1, Math.floor(right - 1e-6));
+ const minTy = Math.floor(top);
+ const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6));
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) s.add(tx + ',' + ty);
+ }
+ return s;
+ }
+
+ /**
+ * ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง
+ */
+ function getCharacterCollisionFootprintWH(md) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let colW = Math.floor(Number(md.characterCollisionW));
+ let colH = Math.floor(Number(md.characterCollisionH));
+ if (!Number.isFinite(colW) || colW < 1) colW = cw;
+ if (!Number.isFinite(colH) || colH < 1) colH = ch;
+ colW = Math.max(1, Math.min(cw, colW));
+ colH = Math.max(1, Math.min(ch, colH));
+ return { cw, ch, colW, colH };
+ }
+
+ function getGauntletCrownHeistMaxEntityXPlay(md) {
+ if (!md || !isGauntletCrownHeistMapPlay()) return null;
+ const w = md.width || 20;
+ const { cw } = getCharacterFootprintWH(md);
+ const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw;
+ const fullMapMax = Math.max(0, w - cw + 0.999);
+ return Math.max(0, Math.min(fullMapMax, capRight));
+ }
+ function clampGauntletCrownHeistEntityXInPlacePlay(ent) {
+ if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return;
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx == null || !Number.isFinite(mx)) return;
+ if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x));
+ if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx));
+ }
+
+ /** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
+ function clampPlayEntityFootprintToMap(oEnt, md) {
+ if (!oEnt || !md) return;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let maxX = Math.max(0, w - cw + 0.999);
+ const crownMx = getGauntletCrownHeistMaxEntityXPlay(md);
+ if (crownMx != null) maxX = Math.min(maxX, crownMx);
+ const maxY = Math.max(0, h - ch + 0.999);
+ if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
+ if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
+ oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
+ oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
+ }
+
+ /** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
+ function separateClumpedPreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const ids = [...others.keys()].filter(isPreviewBotId);
+ const minD = 0.52;
+ const pushAmt = MOVE_SPEED * 0.5;
+ for (let i = 0; i < ids.length; i++) {
+ for (let j = i + 1; j < ids.length; j++) {
+ const a = others.get(ids[i]);
+ const b = others.get(ids[j]);
+ if (!a || !b) continue;
+ let ddx = b.x - a.x, ddy = b.y - a.y;
+ let d = Math.sqrt(ddx * ddx + ddy * ddy);
+ if (d >= minD) continue;
+ if (d < 1e-5) {
+ ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
+ ddy = ((i + j) % 3) - 1;
+ d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
+ }
+ const ux = -ddx / d, uy = -ddy / d;
+ const vx = ddx / d, vy = ddy / d;
+ const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
+ const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
+ if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
+ if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
+ clampPlayEntityFootprintToMap(a, mapData);
+ clampPlayEntityFootprintToMap(b, mapData);
+ }
+ }
+ }
+
+ function quizTilesFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const maxTx = Math.min(w - 1, minTx + cw - 1);
+ const maxTy = Math.min(h - 1, minTy + ch - 1);
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) {
+ if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ /** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */
+ function playEntityFootprintContainsTile(px, py, tx, ty) {
+ return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /**
+ * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
+ * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
+ * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
+ */
+ function resolveBlockPlayerNoOverlapBoxWH(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
+ let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
+ const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
+ const hasW = Number.isFinite(w) && w >= 0;
+ const hasH = Number.isFinite(h) && h >= 0;
+ if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
+ w = Math.min(leg, cw);
+ h = Math.min(leg, ch);
+ } else {
+ if (!hasW) w = 0;
+ if (!hasH) h = 0;
+ }
+ const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
+ const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
+ return { cw, ch, boxW, boxH };
+ }
+
+ /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
+ function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - boxW) / 2);
+ const offY = minTy + (ch - boxH);
+ for (let ty = offY; ty < offY + boxH; ty++) {
+ for (let tx = offX; tx < offX + boxW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
+ return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
+ function quizTilesWallCollisionFootprintPlay(px, py) {
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ return quizBattleSpriteBoundsTilesPlay(mapData, px, py);
+ }
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - colW) / 2);
+ const offY = minTy + (ch - colH);
+ for (let ty = offY; ty < offY + colH; ty++) {
+ for (let tx = offX; tx < offX + colW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function quizAnswerTileForbiddenForLock(lock, tx, ty) {
+ if (!lock || lock.eliminated) return false;
+ if (!mapData) return false;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true;
+ if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true;
+ return false;
+ }
+
+ function quizAnswerTileForbiddenPlay(tx, ty) {
+ if (!playQuizPlayerLocal) return false;
+ return quizAnswerTileForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, tx, ty);
+ }
+
+ /** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */
+ function quizLockFootprintBlocksForLock(lock, px, py) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockFootprintBlocksPlay(px, py) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockFootprintBlocksForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, px, py);
+ }
+
+ /** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */
+ function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ const fromS = quizTilesFootprintPlay(ox, oy);
+ const toS = quizTilesFootprintPlay(nx, ny);
+ for (const k of toS) {
+ if (fromS.has(k)) continue;
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockWouldEnterForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, ox, oy, nx, ny);
+ }
+
+ function botQuizLock(o) {
+ return {
+ cannotTrue: !!(o && o.quizCannotTrue),
+ cannotFalse: !!(o && o.quizCannotFalse),
+ eliminated: false,
+ };
+ }
+
+ /** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
+ function canWalkLikeLobby(x, y, fromX, fromY) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTiles = quizTilesWallCollisionFootprintPlay(x, y);
+ if (wallTiles.size === 0) return false;
+ for (const k of wallTiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTiles.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, o] of others) {
+ if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false;
+ }
+ }
+ }
+ if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksPlay(x, y)) {
+ return false;
+ }
+ }
+ if (quizGhostConfineActivePlay() && !quizGhostFootprintInsidePlay(x, y)) return false;
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) {
+ quizBattleFlashGateBlocked(mapData, x, y, quizBattlePassedComps);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y);
+ for (const k of wallTilesB) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (wallTilesB.size === 0) return false;
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTilesB.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, peer] of others) {
+ if (o && peer === o) continue;
+ if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false;
+ }
+ if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false;
+ }
+ }
+ if (isQuiz()) {
+ const lock = botQuizLock(o);
+ if (lock.cannotTrue || lock.cannotFalse) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksForLock(lock, x, y)) {
+ return false;
+ }
+ }
+ }
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (o && isPreviewBotId(o.id || o.peerId)) { /* preview bots ไม่ล็อกประตู */ }
+ else if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) return false;
+ }
+ return true;
+ }
+
+ /** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */
+ function snapPositionOutOfQuizCarryHubIfNeeded(x, y) {
+ if (!mapData || !isQuizCarry()) return { x, y };
+ if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const maxR = Math.max(w, h) + 6;
+ for (let r = 1; r <= maxR; r++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
+ const nx = x + dx * 0.45;
+ const ny = y + dy * 0.45;
+ if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue;
+ if (quizCarryFootprintOverlapsHub(nx, ny)) continue;
+ if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue;
+ return { x: nx, y: ny };
+ }
+ }
+ }
+ return { x, y };
+ }
+
+ function stepPreviewBotAlongPath(o, w, h) {
+ const path = o.botPath;
+ if (!path || !path.length) return;
+ const way = path[0];
+ const dx = way.x - o.x, dy = way.y - o.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ path.shift();
+ while (path.length > 0) {
+ const w2 = path[0];
+ const ux = w2.x - o.x, uy = w2.y - o.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ path.shift();
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ return;
+ }
+ const len = dist || 1;
+ const carryWalk = isQuizCarry() ? quizCarryWalkSpeedMultActive : 1;
+ const baseEmbedCarryPathMul = 0.74;
+ const pathStepMul = (previewFillBots && editorEmbedReturn)
+ ? (isQuizCarry() ? baseEmbedCarryPathMul * (quizCarryWalkSpeedMultActive / QUIZ_CARRY_WALK_SPEED_MULT) : 0.56)
+ : 1;
+ const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul * carryWalk, len);
+ const nx = o.x + (dx / len) * step;
+ const ny = o.y + (dy / len) * step;
+ if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
+ else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
+ const ox = o.x, oy = o.y;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, nx, ny, (px, py, fx, fy) => canWalkLikeLobbyForBot(px, py, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ } else if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */
+ const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul * carryWalk, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
+ const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
+ const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
+ const tryAxisX = () => {
+ if (sx === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
+ o.x += sx * cstep;
+ return true;
+ };
+ const tryAxisY = () => {
+ if (sy === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
+ o.y += sy * cstep;
+ return true;
+ };
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ if (!tryAxisX()) tryAxisY();
+ } else {
+ if (!tryAxisY()) tryAxisX();
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
+ o.botIsWalking = true;
+ o.botPathStuckTicks = 0;
+ } else {
+ o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
+ if ((o.botPathStuckTicks | 0) >= 20) {
+ o.botPathStuckTicks = 0;
+ if (path.length) path.shift();
+ if (isQuizCarry() && playBotsEnabled()) o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ }
+
+ function pickRandomPreviewBotWanderDir() {
+ const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
+ return dirs[Math.floor(Math.random() * dirs.length)];
+ }
+
+ function stepPreviewBots() {
+ if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
+ if (isQuizCarry()) {
+ stepQuizCarryPreviewBots();
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const now = Date.now();
+ const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botIsWalking = false;
+ /* บอทที่ตอบผิดไปแล้วในแมป mng8a80o → เป็นผีเดินมั่ว ไม่ต้องไปยืนรอที่โซนตอบ */
+ const ghostWanderBot = isQuizQuestionMissionUiMapPlay() && isQuizWrongGhostActiveForEnt(id);
+ if (ghostWanderBot) {
+ /* ข้าม path-following → ตกไป wander logic ด้านล่าง */
+ o.botPath = [];
+ o.botAnswerWander = true;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) {
+ stepPreviewBotAlongPath(o, w, h);
+ return;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) {
+ return;
+ }
+ /* ก่อนตอบ / พัก / บอทสับสน / ผี (ตอบผิดไปแล้ว): เดินทุกเฟรมตามทิศ */
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED;
+ const tox = o.x + accX * step;
+ const toy = o.y + accY * step;
+ const ox = o.x, oy = o.y;
+ const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrictW) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, tox, toy, (nx, ny, fx, fy) => canWalkLikeLobbyForBot(nx, ny, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ if (Math.abs(o.x - ox) < 1e-5 && Math.abs(o.y - oy) < 1e-5) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ } else if (canWalkLikeLobbyForBot(tox, toy, o.x, o.y, o)) {
+ o.x = tox;
+ o.y = toy;
+ } else {
+ if (canWalkLikeLobbyForBot(tox, o.y, o.x, o.y, o)) {
+ o.x = tox;
+ } else if (canWalkLikeLobbyForBot(o.x, toy, o.x, o.y, o)) {
+ o.y = toy;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ }
+
+ /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */
+ function pathfindPlay(fromX, fromY, toX, toY) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ function pathfindPlayForBot(fromX, fromY, toX, toY, o) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ let playPath = [];
+
+ /** เลเซอร์ = รูป cut obstacles-1.png (emitter หัว + ลำแสง + emitter ท้าย ในรูปเดียว) ยืดเต็มความสูง
+ ถ้ารูปยังไม่โหลด → fallback ลำแสงแดงวาดเอง */
+ function drawGauntletLaserColumnScreen(rx, ry, rw, rh) {
+ if (rh <= 1 || rw <= 0) return;
+ const cx = rx + rw / 2;
+ const spriteRec = ensureGauntletAssetImage(BASE + '/img/gauntlet-assets/obstacles-1.png');
+ if (spriteRec && spriteRec.img.complete && spriteRec.img.naturalWidth > 0) {
+ const drawW = Math.max(14, rw * 1.5);
+ try { ctx.drawImage(spriteRec.img, cx - drawW / 2, ry, drawW, rh); } catch (e) { /* ignore */ }
+ return;
+ }
+ ctx.save();
+ const coreW = Math.max(2, rw * 0.30);
+ const glowW = Math.max(coreW + 4, rw * 0.92);
+ const prevComp = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ /* glow รอบนอก */
+ let g1 = ctx.createLinearGradient(cx - glowW / 2, 0, cx + glowW / 2, 0);
+ g1.addColorStop(0, 'rgba(255,40,60,0)');
+ g1.addColorStop(0.5, 'rgba(255,55,75,0.5)');
+ g1.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = g1;
+ ctx.fillRect(cx - glowW / 2, ry, glowW, rh);
+ /* แกนสว่าง */
+ let g2 = ctx.createLinearGradient(cx - coreW / 2, 0, cx + coreW / 2, 0);
+ g2.addColorStop(0, 'rgba(255,110,130,0.85)');
+ g2.addColorStop(0.5, 'rgba(255,240,245,0.98)');
+ g2.addColorStop(1, 'rgba(255,110,130,0.85)');
+ ctx.fillStyle = g2;
+ ctx.fillRect(cx - coreW / 2, ry, coreW, rh);
+ ctx.globalCompositeOperation = prevComp;
+ /* หัว/ท้าย emitter — รูปจริง gauntlet-dd51 (gauntletLaserTopUrl/BottomUrl) ที่บน-ล่างสุดของลำแสง
+ กล่องเป็นสีเข้ม → ใส่ glow แดงข้างหลังให้เด่นบนพื้นมืด (ผนัง museum) */
+ const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null;
+ const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null;
+ const capW = Math.max(18, rw * 2.0);
+ function laserEmitterGlow(gy) {
+ const rad = capW * 0.8;
+ const pc = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ const gg = ctx.createRadialGradient(cx, gy, 0, cx, gy, rad);
+ gg.addColorStop(0, 'rgba(255,120,140,0.9)');
+ gg.addColorStop(0.5, 'rgba(255,55,75,0.55)');
+ gg.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = gg;
+ ctx.beginPath();
+ ctx.arc(cx, gy, rad, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalCompositeOperation = pc;
+ }
+ if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) {
+ const ch = capW * topRec.img.naturalHeight / topRec.img.naturalWidth;
+ laserEmitterGlow(ry + ch * 0.35);
+ try { ctx.drawImage(topRec.img, cx - capW / 2, ry - ch * 0.1, capW, ch); } catch (e) { /* ignore */ }
+ }
+ if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) {
+ const ch = capW * botRec.img.naturalHeight / botRec.img.naturalWidth;
+ laserEmitterGlow(ry + rh - ch * 0.35);
+ /* ท้าย — กลับหัวรูป (เครื่องยิงหันขึ้น) วางชิดล่างสุด */
+ ctx.save();
+ ctx.translate(cx, ry + rh - ch * 0.9);
+ ctx.scale(1, -1);
+ try { ctx.drawImage(botRec.img, -capW / 2, 0, capW, ch); } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.restore();
+ }
+
+ /** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */
+ function drawStackPreviewCyberHud(ctx, cw, ch) {
+ if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return;
+ const now = Date.now();
+ const pad = 12;
+ const currentSeat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ const monoSm = '9px ui-monospace, Consolas, monospace';
+ const towerMapHud = isStackTowerMissionUiMapPlay();
+ /* MG3 live mission: HUD ย้ายไป DOM (#mg3-tower-hud) แล้ว — ไม่วาดบน canvas (object-fit:cover ครอปขอบหาย) */
+ if (towerMapHud) return;
+ const lifeSlots = towerMapHud
+ ? Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)))
+ : 3;
+ const livesRem = towerMapHud && stackMini.teamMissesLeft != null
+ ? Math.max(0, Number(stackMini.teamMissesLeft) || 0)
+ : Math.max(0, stackMini.lives != null ? stackMini.lives : 3);
+ const teamScore = stackMini.score || 0;
+ const combo = stackMini.combo || 0;
+
+ function roundRectPath(x, y, w, h, r) {
+ const rr = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ ctx.closePath();
+ }
+
+ /** @param {{ headBust?: boolean, headSrcFrac?: number, bustLiftPx?: number }} [avatarOpts] — headBust + bustLiftPx ดันรูปขึ้นในกรอบ */
+ function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint, avatarOpts) {
+ const timeMs = now;
+ const dir = 'down';
+ const rawImg = getAvatarImg(characterId, dir, timeMs, false);
+ const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
+ let iw = 0; let ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ const headBust = !!(avatarOpts && avatarOpts.headBust);
+ let sx = 0;
+ let sy = 0;
+ let sww = iw;
+ let shh = ih;
+ if (headBust && iw > 0 && ih > 0) {
+ const rawF = Number(avatarOpts && avatarOpts.headSrcFrac);
+ const frac = Number.isFinite(rawF) && rawF > 0.22 && rawF < 0.85 ? rawF : 0.4;
+ shh = Math.max(8, Math.round(ih * frac));
+ }
+ const ax = cx - maxSize / 2;
+ const ay = baselineY - maxSize;
+ ctx.save();
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.clip();
+ if (iw > 0 && ih > 0) {
+ const maxScale = headBust ? 2.45 : 1;
+ const scale = Math.min(maxSize / sww, maxSize / shh, maxScale);
+ const dw = sww * scale;
+ const dh = shh * scale;
+ const rawLift = headBust && avatarOpts ? Number(avatarOpts.bustLiftPx) : NaN;
+ const bustLift = headBust
+ ? (Number.isFinite(rawLift) ? Math.max(0, Math.min(maxSize * 0.45, rawLift)) : 22)
+ : 0;
+ ctx.drawImage(charImg, sx, sy, sww, shh, cx - dw / 2, baselineY - dh - bustLift, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
+ ctx.fillRect(ax, ay, maxSize, maxSize);
+ }
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.stroke();
+ }
+
+ function resolveEntryVisual(entry) {
+ if (entry.kind === 'human') {
+ return {
+ name: String(me.nickname || 'YOU').toUpperCase(),
+ score: me.stackPreviewHumanPts || 0,
+ characterId: me.characterId,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId || 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
+ score: o ? (o.stackBotScore || 0) : 0,
+ characterId: o ? o.characterId : null,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+ const boardW = Math.min(248, Math.max(200, cw * 0.36));
+ const rowH = 42;
+ const headH = 38;
+ const boardH = headH + STACK_PREVIEW_TURN_COUNT * rowH + 10;
+
+ if (!towerMapHud) {
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.stroke();
+
+ ctx.fillStyle = '#5cefff';
+ ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('SCORE', pad + 14, 16);
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
+ ctx.font = monoSm;
+ ctx.fillText('shared stack · P1–P6', pad + 62, 18);
+
+ let rowY = 8 + headH;
+ const drawRows = (entries) => {
+ entries.forEach((entry) => {
+ const isCurrent = entry.seat === currentSeat;
+ const v = resolveEntryVisual(entry);
+ let actionFlash = false;
+ if (entry.kind === 'human') {
+ actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
+ } else if (entry.botId) {
+ const ob = others.get(entry.botId);
+ actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
+ }
+ if (isCurrent) {
+ ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
+ ctx.lineWidth = 2;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ } else if (actionFlash) {
+ ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ }
+ const avS = 34;
+ drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
+ ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
+ ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = 'bold 10px ' + mono;
+ ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
+ ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
+ ctx.font = 'bold 13px ' + mono;
+ ctx.textAlign = 'right';
+ ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
+ ctx.textAlign = 'left';
+ rowY += rowH;
+ });
+ };
+
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ drawRows(order);
+ } else {
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const rows = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ drawRows(rows);
+ }
+ }
+
+ const framePad = 10;
+ const bigAv = Math.min(72, Math.max(52, ch * 0.10));
+ const avBox = bigAv + 10;
+ const integrityMinW = 120;
+ const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34)));
+ let rightX = cw - pad - rightW;
+ const topY = 8;
+ const leftReserve = towerMapHud ? (pad + 8) : (pad + boardW + 14);
+ if (rightX < leftReserve) {
+ rightX = Math.max(pad, cw - pad - rightW);
+ }
+ const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10);
+ const innerTop = topY + framePad;
+ const integrityX = rightX + framePad;
+ const integrityH = avBox + 2;
+ const avFrameX = integrityX + integrityW + 10;
+ const avFrameY = innerTop;
+ const footerH = towerMapHud ? 0 : 26;
+ const turnPanelH = framePad + integrityH + (towerMapHud ? framePad : (10 + footerH + framePad));
+
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.stroke();
+
+ let curEntry = null;
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ curEntry = order.find((e) => e.seat === currentSeat) || null;
+ }
+ const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
+
+ drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem);
+
+ const avCx = avFrameX + avBox / 2;
+ ctx.save();
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.5)';
+ ctx.shadowBlur = 12;
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)';
+ ctx.lineWidth = 2;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)';
+ ctx.lineWidth = 1;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint, towerMapHud ? { headBust: true } : undefined);
+
+ if (!towerMapHud) {
+ const footY = innerTop + integrityH + 12;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)';
+ ctx.font = monoSm;
+ ctx.fillText(
+ 'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo,
+ rightX + rightW / 2,
+ footY,
+ );
+ }
+
+ const termW = Math.min(380, Math.max(220, cw * 0.42));
+ const logLineH = 12;
+ const logMaxLines = 5;
+ const logBlockH = logMaxLines * logLineH;
+ /** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */
+ const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10);
+ const termY = Math.max((towerMapHud ? 28 : boardH + 24), ch - termH - 52);
+ ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.stroke();
+
+ ctx.fillStyle = '#c099ff';
+ ctx.font = '600 10px ' + mono;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = monoSm;
+ const logTop = termY + 24;
+ let ly = logTop;
+ const logLines = stackPreviewHudLog.length
+ ? stackPreviewHudLog.slice(-logMaxLines)
+ : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
+ logLines.forEach((ln) => {
+ ctx.fillText(ln, pad + 10, ly);
+ ly += logLineH;
+ });
+
+ const decryptPct = stackTowerDecryptPctPlay();
+ const barPadX = 10;
+ const barH = 8;
+ const barW = termW - barPadX * 2;
+ const barY = termY + termH - barH - 10;
+ const labelY = barY - 14;
+ if (labelY >= logTop + 4) {
+ ctx.fillStyle = '#94e2d5';
+ ctx.font = monoSm;
+ ctx.textBaseline = 'top';
+ ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY);
+ }
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.fillRect(pad + barPadX, barY, barW, barH);
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
+ ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH);
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
+ ctx.strokeRect(pad + barPadX, barY, barW, barH);
+
+ ctx.restore();
+ }
+
+ function useCyberPlayHud() {
+ return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()
+ || isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay()));
+ }
+
+ /** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
+ function cyberHudQuizCarryPeerScore(peerId) {
+ if (peerId == null) return 0;
+ return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
+ }
+
+ /** แมป mng8a80o ช่วงเล่น: คำถามอยู่บนแผนที่ (โซนทอง) — HUD กลางเหลือ TIME + แผ่นเวลา */
+ function syncQuizQuestionMissionCyberCenterHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const qBand = document.getElementById('play-cyber-quiz-mission-q-band');
+ const qText = document.getElementById('play-cyber-quiz-mission-q-text');
+ const qPlaque = document.getElementById('play-cyber-quiz-mission-q-plaque');
+ const tb = root ? root.querySelector('.play-cyber-time-block') : null;
+ const on = isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (qBand) {
+ qBand.classList.add('is-hidden');
+ qBand.setAttribute('aria-hidden', 'true');
+ }
+ if (qText) qText.textContent = '';
+ if (qPlaque) {
+ qPlaque.classList.add('is-hidden');
+ qPlaque.removeAttribute('src');
+ delete qPlaque.dataset.qmQsrc;
+ }
+ }
+ const plaqueImg = root ? document.getElementById('play-cyber-time-plaque-img') : null;
+ const head = tb && tb.querySelector ? tb.querySelector('.play-cyber-time-head') : null;
+ const clearQmTimePlaque = () => {
+ if (plaqueImg) {
+ plaqueImg.onload = null;
+ plaqueImg.onerror = null;
+ plaqueImg.removeAttribute('src');
+ plaqueImg.classList.add('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'true');
+ delete plaqueImg.dataset.qmTimeSrc;
+ }
+ if (head) head.classList.remove('play-cyber-time-head--qm-plaque');
+ if (tb) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ delete tb.dataset.qmTimeSrc;
+ }
+ };
+ if (tb && plaqueImg && head) {
+ const tUrl = questionMissionHudAssetUrl('time.png');
+ if (on) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ const applyPlaque = () => {
+ head.classList.add('play-cyber-time-head--qm-plaque');
+ plaqueImg.classList.remove('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'false');
+ };
+ if (plaqueImg.dataset.qmTimeSrc === tUrl && plaqueImg.getAttribute('src') === tUrl
+ && plaqueImg.complete && (plaqueImg.naturalWidth || 0) > 0) {
+ applyPlaque();
+ } else if (plaqueImg.dataset.qmTimeSrc !== tUrl || plaqueImg.getAttribute('src') !== tUrl) {
+ plaqueImg.dataset.qmTimeSrc = tUrl;
+ plaqueImg.onload = function () {
+ if ((plaqueImg.naturalWidth || 0) > 0) applyPlaque();
+ else clearQmTimePlaque();
+ };
+ plaqueImg.onerror = function () {
+ clearQmTimePlaque();
+ };
+ plaqueImg.setAttribute('src', tUrl);
+ }
+ } else {
+ clearQmTimePlaque();
+ }
+ }
+ }
+
+ function playCyberHudEsc(t) {
+ return String(t == null ? '' : t)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function resolveCyberHudAvatarTintPlay(row) {
+ if (row && row.playTint && row.playTint.head) return row.playTint;
+ const peerId = row && row.id != null ? row.id : '';
+ if (row && row.isMe) {
+ return me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me'));
+ }
+ const sid = peerId !== '' ? String(peerId) : '';
+ const ox = sid ? (others.get(peerId) || others.get(sid)) : null;
+ if (ox && ox.playTint) return ox.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ function resolveRankedCharacterIdPlay(rankedRow) {
+ if (!rankedRow) return '';
+ if (rankedRow.characterId) return String(rankedRow.characterId);
+ const id = rankedRow.id;
+ if (id == null) return '';
+ const ox = others.get(id) || others.get(String(id));
+ if (ox && ox.characterId) return String(ox.characterId);
+ if (myId != null && String(id) === String(myId)) {
+ return String(me.characterId || getPlayCharacterId() || '');
+ }
+ return '';
+ }
+
+ /** กัน cache/แสดง canvas ว่าง (ทำให้ช่องอันดับ 1 หายรูป แต่ retry หยุดเพราะ naturalWidth > 0) */
+ function playCanvasHasOpaquePixels(canvas) {
+ if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
+ try {
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ for (let i = 3; i < data.length; i += 16) {
+ if (data[i] > 12) return true;
+ }
+ } catch (e) { /* ignore */ }
+ return false;
+ }
+
+ function ensureMePlayTintFromLobbyStorage() {
+ if (me.playTint && me.playTint.head && me.playTint.body) return;
+ const t = loadPlayTintFromLobbyPlayerStorage();
+ if (t) me.playTint = t;
+ }
+
+ function cyberHudScoreAvatarCacheKey(row) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ return [row.isMe ? 'me' : String(row.id), cid, tint.head, tint.hair, tint.body, 'faceV4'].join('|');
+ }
+
+ function findPlayCyberHudScoreAvatarSrcForPeer(peerId) {
+ if (peerId == null) return '';
+ const sid = String(peerId).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return '';
+ const row = ul.querySelector('.play-cyber-score-row[data-peer-id="' + sid + '"]');
+ if (!row) return '';
+ const av = row.querySelector('img.play-cyber-score-av');
+ if (!av || !av.src || av.src === defaultAvatarImg.src) return '';
+ if (!av.complete || av.naturalWidth < 8) return '';
+ return av.src;
+ }
+
+ /** crop หน้า idle ลง (down) + tint — ใช้ทั้ง HUD และหน้าสรุปผล ให้ตรงสีในเกม */
+ function composeCyberHudFacePortraitDataUrl(cid, tint) {
+ if (!cid || !tint) return '';
+ const dir = 'down';
+ const nowT = Date.now();
+ preloadPlayTintForAvatar(cid, tint);
+ ensurePlayLayerProbesAllDirections(cid);
+ const raw = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (!raw) return '';
+ let srcCanvas = null;
+ if (raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ const composeFrame = isUploadedCharAssetId(cid) ? 0 : idleAnimPhaseIndex(nowT);
+ const packIdle = tryComposePlayLayersFromFiles(raw, cid, dir, composeFrame, tint, { idle: true });
+ if (packIdle && packIdle.canvas && playCanvasHasOpaquePixels(packIdle.canvas)) {
+ srcCanvas = packIdle.canvas;
+ }
+ if (!srcCanvas) {
+ const tinted = getPlayTintedAvatarSource(raw, cid, dir, nowT, false, tint);
+ if (tinted && tinted.tagName === 'CANVAS' && tinted.width > 0 && playCanvasHasOpaquePixels(tinted)) {
+ srcCanvas = tinted;
+ }
+ }
+ }
+ /* ตัวละครที่ไม่มีไฟล์เลเยอร์สี (เช่น defaultChar) — compose คืน Image ดิบ → crop หน้าจากรูปดิบตรง ๆ กันช่องว่าง */
+ let drawSrc = srcCanvas;
+ if (!drawSrc && raw && raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ /* ตัวที่มีไฟล์เลเยอร์สี (tintable) แต่ composite ยังไม่พร้อม หรือ API characters ยังโหลดไม่เสร็จ →
+ คืน '' ให้ applyCyberHudScoreAvatarImgWithRetry retry รอเลเยอร์โหลด (avatar bot จะไม่ถูกรับจนกว่าจะเป็น data URL ที่ย้อมสีแล้ว)
+ อย่า fallback raw ที่ "ไม่มีสี" ไม่งั้น avatar ทุกคนเหมือนกัน = "สีไม่ขึ้น"; ใช้ raw เฉพาะตัวที่ API ยืนยันว่าไม่มีเลเยอร์ (defaultChar) */
+ if (playCharLayerApi.status !== 'done' || playCharLayerFromApi(cid) === true) return '';
+ drawSrc = raw;
+ }
+ if (!drawSrc) return '';
+ const face = document.createElement('canvas');
+ face.width = 120;
+ face.height = 120;
+ const ctx = face.getContext('2d');
+ if (!ctx) return '';
+ const sw = drawSrc.naturalWidth || drawSrc.width;
+ const sh = drawSrc.naturalHeight || drawSrc.height;
+ if (!sw || !sh) return '';
+ const sx = sw * 0.1;
+ const sy = sh * 0.02;
+ const sw2 = sw * 0.8;
+ const sh2 = sh * 0.5;
+ const cropSize = Math.min(sw2, sh2);
+ const sxCenter = sx + (sw2 - cropSize) * 0.5;
+ ctx.clearRect(0, 0, 120, 120);
+ try {
+ /* crop สี่เหลี่ยมจัตุรัสจากหัว → 120×120 ไม่ยืด */
+ ctx.drawImage(drawSrc, sxCenter, sy, cropSize, cropSize, 0, 0, 120, 120);
+ } catch (e) {
+ return '';
+ }
+ if (!playCanvasHasOpaquePixels(face)) return '';
+ try {
+ return face.toDataURL('image/png');
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function cyberHudScoreAvatarImgLooksReady(img) {
+ return !!(img && img.src && img.src !== defaultAvatarImg.src && img.complete && img.naturalWidth > 8 && img.naturalHeight > 8);
+ }
+
+ /** crop หัว (จัตุรัส) จาก src เต็มตัว (lobby idle — ย้อมสีถูกแล้ว) → 120×120 data URL (async, cache)
+ * ใช้เมื่อ composeCyberHudFacePortraitDataUrl คืน '' (เช่นตัวที่ layer ไม่ compose) → human เป็น "หน้า" เหมือนบอท ไม่ใช่เต็มตัว */
+ var cyberHudLobbyFaceCropCache = new Map();
+ function cyberHudLobbyIdleHeadFaceDataUrl(src) {
+ if (!src) return '';
+ if (src.indexOf('data:') === 0 && src.length < 200) return '';
+ if (cyberHudLobbyFaceCropCache.has(src)) return cyberHudLobbyFaceCropCache.get(src);
+ cyberHudLobbyFaceCropCache.set(src, '');
+ var im = new Image();
+ im.onload = function () {
+ try {
+ var sw = im.naturalWidth, sh = im.naturalHeight;
+ if (!sw || !sh) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var c = document.createElement('canvas');
+ c.width = 120; c.height = 120;
+ var ctx = c.getContext('2d');
+ if (!ctx) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var cw = sw * 0.8, ch = sh * 0.5, cs = Math.min(cw, ch);
+ var sxC = sw * 0.1 + (cw - cs) * 0.5, syC = sh * 0.02;
+ ctx.clearRect(0, 0, 120, 120);
+ ctx.drawImage(im, sxC, syC, cs, cs, 0, 0, 120, 120);
+ cyberHudLobbyFaceCropCache.set(src, playCanvasHasOpaquePixels(c) ? c.toDataURL('image/png') : '');
+ } catch (e) { cyberHudLobbyFaceCropCache.set(src, ''); }
+ };
+ im.onerror = function () { cyberHudLobbyFaceCropCache.set(src, ''); };
+ im.src = src;
+ return '';
+ }
+
+ /** โปรไฟล์มุมขวาบน — mission mock ใช้หน้า crop สี่เหลี่ยม ไม่ดึง sprite เต็มตัว */
+ function setCyberHudProfilePortraitImg(portraitImg) {
+ if (!portraitImg) return;
+ const cid = me.characterId || getPlayCharacterId();
+ if (!cid) {
+ portraitImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const row = {
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ if (isMissionMockHudPlay()) {
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ const cacheKey = cyberHudScoreAvatarCacheKey(row) + '|profile';
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ portraitImg.src = cached;
+ return;
+ }
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ portraitImg.src = faceUrl;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ }
+ setCyberHudScoreAvatarImg(portraitImg, row);
+ }
+
+ function applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt) {
+ attempt = attempt || 0;
+ setCyberHudProfilePortraitImg(portraitImg);
+ if (cyberHudScoreAvatarImgLooksReady(portraitImg)) return;
+ if (attempt >= 16) return;
+ const cid = me.characterId || getPlayCharacterId();
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) {
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ }));
+ }
+ applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt + 1);
+ }, delay);
+ }
+
+ /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */
+ function setCyberHudScoreAvatarImg(avImg, row, skipReuse) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ if (!cid) {
+ avImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const cacheKey = cyberHudScoreAvatarCacheKey(row);
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ avImg.src = cached;
+ return;
+ }
+ /* skipReuse: ตอน retry (เช่น crop หัวยังไม่เสร็จ) อย่าอ่าน src ปัจจุบันจาก DOM กลับมา
+ ไม่งั้นจะได้ "รูปเต็มตัวชั่วคราว" กลับมาแล้ว cache ค้าง (human โชว์เต็มตัวคนเดียว) */
+ const hudSrc = skipReuse ? '' : findPlayCyberHudScoreAvatarSrcForPeer(row.id);
+ if (hudSrc) {
+ avImg.src = hudSrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, hudSrc);
+ return;
+ }
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ /* ย้อมสีตาม tint ปัจจุบันก่อนเสมอ (รวมตัวเรา) — อย่าใช้ lobby idle ต่อ characterId เพราะสีไม่ตรงที่เลือกใน lobby */
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ avImg.src = faceUrl;
+ if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) {
+ const first = cyberHudScoreAvatarUrlCache.keys().next();
+ if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value);
+ }
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ if (row.isMe) {
+ const lobbySrc = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (lobbySrc) {
+ if (isMissionMockHudPlay()) {
+ /* mock hud (mg2 ฯลฯ): crop หัวจาก lobby idle → "หน้า" จัตุรัสเหมือนบอท (กัน human โชว์เต็มตัวคนเดียว) */
+ const lobbyFace = cyberHudLobbyIdleHeadFaceDataUrl(lobbySrc);
+ if (lobbyFace) {
+ avImg.src = lobbyFace;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbyFace);
+ return;
+ }
+ /* crop ยังไม่เสร็จ (async) → ใช้ "หน้า default" ชั่วคราว (อย่าโชว์เต็มตัว — ผู้เล่นต้องเห็นแค่หัวเหมือนบอท) แล้ว retry จนได้หัวจริง
+ จำกัดจำนวน retry กัน loop ถ้า crop ล้มเหลวถาวร */
+ avImg.src = defaultAvatarImg.src;
+ var hudAvTries = avImg.__hudAvHeadTries || 0;
+ if (hudAvTries < 12) {
+ avImg.__hudAvHeadTries = hudAvTries + 1;
+ setTimeout(function () {
+ if (avImg.isConnected) setCyberHudScoreAvatarImg(avImg, row, true);
+ }, 200);
+ }
+ return;
+ }
+ avImg.src = lobbySrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbySrc);
+ return;
+ }
+ }
+ const dir = 'down';
+ const nowT = Date.now();
+ const frameIm = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (frameIm && frameIm.src) {
+ if (!frameIm.complete || !frameIm.naturalWidth) {
+ avImg.src = frameIm.src;
+ frameIm.addEventListener('load', function onHudAvFrame() {
+ frameIm.removeEventListener('load', onHudAvFrame);
+ setCyberHudScoreAvatarImg(avImg, row);
+ }, { once: true });
+ return;
+ }
+ }
+ avImg.src = defaultAvatarImg.src;
+ }
+
+ /** ลองโหลด avatar ซ้ำจน sprite/tint พร้อม (กันรูปหายตอนเปิด overlay) */
+ function applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt) {
+ attempt = attempt || 0;
+ setCyberHudScoreAvatarImg(avImg, row);
+ /* mock hud: ต้องได้ "หน้า" 120×120 (naturalWidth≥100) เท่านั้นถึงหยุด — กันค้างที่ raw frame 64×64 (เต็มตัว)
+ หรือ /img/ sprite เต็มตัว ก่อน composeCyberHudFacePortraitDataUrl จะได้หน้า (ตอน char-layer API ยังโหลด) */
+ const mockFaceOk = !isMissionMockHudPlay() || (avImg.naturalWidth >= 100 && avImg.naturalHeight >= 100);
+ const ok = cyberHudScoreAvatarImgLooksReady(avImg) && mockFaceOk
+ && (avImg.src.indexOf('data:image/') === 0 || (row.isMe && !isMissionMockHudPlay() && avImg.src.indexOf('/img/') >= 0));
+ if (ok) return;
+ if (attempt >= 16) return;
+ const cid = row.characterId ? String(row.characterId) : '';
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay(row));
+ applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt + 1);
+ }, delay);
+ }
+
+ function refreshGcmTfMockAvatarsPlay(rowEl, ranked) {
+ if (!rowEl || !Array.isArray(ranked)) return;
+ const imgs = rowEl.querySelectorAll('img.gcm-av');
+ ranked.forEach(function (r, idx) {
+ const img = imgs[idx];
+ if (!img) return;
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ });
+ }
+
+ function quizQuestionMissionJoystickResetVisual() {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (knob) knob.style.transform = 'translate(-50%, -50%)';
+ }
+
+ function quizQuestionMissionJoystickUpdateFromClientXY(clientX, clientY) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (!base || !knob) return;
+ const r = base.getBoundingClientRect();
+ const cx = r.left + r.width * 0.5;
+ const cy = r.top + r.height * 0.5;
+ let dx = clientX - cx;
+ let dy = clientY - cy;
+ const half = Math.min(r.width, r.height) * 0.5;
+ const maxKn = Math.max(18, half * 0.52);
+ const len = Math.hypot(dx, dy);
+ if (len > maxKn && len > 1e-6) {
+ dx = (dx / len) * maxKn;
+ dy = (dy / len) * maxKn;
+ }
+ const nlen = Math.hypot(dx, dy);
+ const dead = maxKn * 0.12;
+ if (nlen < dead) {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ knob.style.transform = 'translate(-50%, -50%)';
+ return;
+ }
+ quizQuestionMissionJoyVecX = dx / maxKn;
+ quizQuestionMissionJoyVecY = dy / maxKn;
+ knob.style.transform = 'translate(calc(-50% + ' + dx + 'px), calc(-50% + ' + dy + 'px))';
+ }
+
+ function syncQuizQuestionMissionJoystickPlay() {
+ const root = document.getElementById('quiz-question-mission-joystick');
+ if (!root) return;
+ const jumperOn = isJumpSurviveMissionHudActivePlay();
+ const on = !!((isQuizQuestionMissionHudActivePlay() || jumperOn) && !isChatFocused());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (on) {
+ /* สลับรูป joystick ตามฉาก: jumper → /img/Jumper/, quiz คำถาม → /img/QUESTION/ (เปลี่ยน src เฉพาะตอน skin เปลี่ยน) */
+ const skin = jumperOn ? 'jumper' : 'question';
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (bg && bg.dataset.joySkin !== skin) {
+ bg.src = jumperOn ? jumperAssetUrl('btn-joystick-1.png') : questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = skin;
+ }
+ if (knobImg && knobImg.dataset.joySkin !== skin) {
+ knobImg.src = jumperOn ? jumperAssetUrl('btn-joystick-2.png') : questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = skin;
+ }
+ }
+ if (!on) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ if (base && quizQuestionMissionJoyPointerId != null) {
+ try {
+ base.releasePointerCapture(quizQuestionMissionJoyPointerId);
+ } catch (_e) { /* ignore */ }
+ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ }
+
+ /* mng8a80o: scale HUD ทั้งก้อน (fix 1920×1080) ให้พอดี container แบบรักษา 16:9 — เทียบ scale.js ของ mockup Minigame-1
+ สำคัญ: คำนวณเฉพาะตอนเข้าโหมด/resize เท่านั้น (ห้ามอ่าน layout ทุกเฟรมใน draw loop → กระตุก) */
+ let _quizTfScaleLast = 0;
+ let _quizTfMockActivePrev = false;
+ function updateQuizTfMockScale(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root || !root.classList.contains('play-cyber-hud--quiz-tf-mock')) return;
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ root.style.position = 'fixed';
+ root.style.left = Math.round(r.left) + 'px';
+ root.style.top = Math.round(r.top) + 'px';
+ root.style.width = Math.round(cw) + 'px';
+ root.style.height = Math.round(ch) + 'px';
+ root.style.right = 'auto';
+ root.style.bottom = 'auto';
+ root.style.transform = 'none';
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ if (Math.abs(s - _quizTfScaleLast) < 0.0005
+ && root.dataset.qtfStackW === String(Math.round(cw))
+ && root.dataset.qtfStackH === String(Math.round(ch))) {
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ return;
+ }
+ _quizTfScaleLast = s;
+ root.dataset.qtfStackW = String(Math.round(cw));
+ root.dataset.qtfStackH = String(Math.round(ch));
+ root.style.setProperty('--qtf-scale', String(s));
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ }
+
+ function syncMg2MockJumpButtonBoundsPlay(stackRect, scale) {
+ const jumpBtn = document.getElementById('gauntlet-crown-jump-btn');
+ const hud = document.getElementById('play-cyber-hud');
+ const _jbSkin = hud ? hud.dataset.missionMockSkin : '';
+ if (!jumpBtn || !hud || (_jbSkin !== 'gauntlet_crown' && _jbSkin !== 'jumper')) {
+ if (jumpBtn) {
+ jumpBtn.style.removeProperty('left');
+ jumpBtn.style.removeProperty('top');
+ jumpBtn.style.removeProperty('width');
+ jumpBtn.style.removeProperty('height');
+ jumpBtn.style.removeProperty('right');
+ jumpBtn.style.removeProperty('bottom');
+ jumpBtn.style.removeProperty('transform');
+ jumpBtn.style.removeProperty('filter');
+ }
+ return;
+ }
+ if (jumpBtn.classList.contains('is-hidden')) return;
+ const r = stackRect || (document.getElementById('play-canvas-stack') || {}).getBoundingClientRect?.();
+ const s = Number.isFinite(scale) ? scale : _quizTfScaleLast;
+ if (!r || !(s > 0)) return;
+ jumpBtn.style.position = 'fixed';
+ jumpBtn.style.left = Math.round(r.left + 1648 * s) + 'px';
+ jumpBtn.style.top = Math.round(r.top + 818 * s) + 'px';
+ jumpBtn.style.width = Math.round(231 * s) + 'px';
+ jumpBtn.style.height = Math.round(231 * s) + 'px';
+ jumpBtn.style.right = 'auto';
+ jumpBtn.style.bottom = 'auto';
+ jumpBtn.style.transform = 'none';
+ jumpBtn.style.filter = 'drop-shadow(0 0 ' + Math.max(8, Math.round(18 * s)) + 'px rgba(255, 110, 220, 0.45))';
+ }
+
+ function clearQuizTfMockHudBoundsPlay(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root) return;
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'true');
+ root.style.removeProperty('left');
+ root.style.removeProperty('top');
+ root.style.removeProperty('width');
+ root.style.removeProperty('height');
+ root.style.removeProperty('right');
+ root.style.removeProperty('bottom');
+ root.style.removeProperty('transform');
+ delete root.dataset.qtfStackW;
+ delete root.dataset.qtfStackH;
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ }
+ /* mng8a80o: สรุปผลภารกิจ (06-summary) — scale popup 1451×851 ตามสูตรเดียวกับ HUD (min vw/1920, vh/1080) */
+ function missionMockOverlayScalePlay() {
+ const stack = document.getElementById('play-canvas-stack');
+ const r = stack && stack.getBoundingClientRect ? stack.getBoundingClientRect() : null;
+ const cw = Math.max(1, r && r.width ? r.width : window.innerWidth);
+ const ch = Math.max(1, r && r.height ? r.height : window.innerHeight);
+ return Math.min(cw / 1920, ch / 1080);
+ }
+ function updateGcmTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-mission-overlay');
+ if (!ov) return;
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gcm-tf-scale', String(s));
+ }
+ /* popup HOW TO PLAY (01-howto) — scale stage 1920×1080 · MG2 ยึด #play-canvas-stack เหมือน HUD */
+ function updateGchTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ const mg2 = ov.classList.contains('gch-mg2-mock');
+ if (mg2) {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ ov.style.position = 'fixed';
+ ov.style.left = Math.round(r.left) + 'px';
+ ov.style.top = Math.round(r.top) + 'px';
+ ov.style.width = Math.round(cw) + 'px';
+ ov.style.height = Math.round(ch) + 'px';
+ ov.style.right = 'auto';
+ ov.style.bottom = 'auto';
+ ov.style.transform = 'none';
+ ov.style.setProperty('--gch-tf-scale', String(s));
+ const stage = ov.querySelector('.gch-mg2-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ return;
+ }
+ clearGchMg2OverlayBoundsPlay(ov);
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gch-tf-scale', String(s));
+ }
+ let _quizTfScaleObserversBound = false;
+ function ensureQuizTfScaleResizeListener() {
+ if (_quizTfScaleObserversBound) return;
+ _quizTfScaleObserversBound = true;
+ const recompute = function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ if (ov && ov.classList.contains('gcm-quiz-tf-mock') && !ov.classList.contains('is-hidden')) updateGcmTfMockScale(ov);
+ const hv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hv && hv.classList.contains('gch-quiz-tf-mock') && !hv.classList.contains('is-hidden')) updateGchTfMockScale(hv);
+ };
+ window.addEventListener('resize', recompute);
+ window.addEventListener('orientationchange', recompute);
+ /* ResizeObserver จับ container ที่เปลี่ยนขนาดโดยไม่มี window resize (เช่น ลากแบ่งจอ editor) */
+ if (typeof ResizeObserver === 'function') {
+ try {
+ const ro = new ResizeObserver(recompute);
+ const host = document.querySelector('.game-wrap') || document.documentElement;
+ const stack = document.getElementById('play-canvas-stack');
+ if (host) ro.observe(host);
+ if (stack) ro.observe(stack);
+ } catch (e) { /* ignore */ }
+ }
+ }
+
+ function syncPlayCyberHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const on = useCyberPlayHud();
+ if (root) {
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) {
+ root.classList.remove('play-cyber-hud--question-mission');
+ root.classList.remove('play-cyber-hud--score-flush-left');
+ root.classList.remove('play-cyber-hud--jump-mission-hide-score');
+ root.classList.remove('play-cyber-hud--ss-self-integrity-left');
+ root.classList.remove('play-cyber-hud--last-light-no-time');
+ root.classList.remove('play-cyber-hud--quiz-tf-mock');
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ _quizTfMockActivePrev = false;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ }
+ if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on);
+ const gw = document.querySelector('.game-wrap');
+ if (gw) gw.classList.toggle('play-cyber-active', !!on);
+ if (!on || !root) return;
+ ensureMePlayTintFromLobbyStorage();
+ root.classList.toggle(
+ 'play-cyber-hud--question-mission',
+ !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay()),
+ );
+ /* Minigame 1–7 mission — HUD fix 1920×1080 ตาม mock (score-bar / profile qb-room) */
+ const missionMockOn = !!isMissionMockHudPlay();
+ root.classList.toggle('play-cyber-hud--quiz-tf-mock', missionMockOn);
+ document.documentElement.classList.toggle('play-mission-mock-hud', missionMockOn && on);
+ document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && getMissionMockHudSkinPlay() === 'gauntlet_crown');
+ applyMissionMockHudCssVarsPlay(root);
+ const tfMockOn = root.classList.contains('play-cyber-hud--quiz-tf-mock');
+ if (tfMockOn) {
+ ensureQuizTfScaleResizeListener();
+ updateQuizTfMockScale(root);
+ if (!_quizTfMockActivePrev) {
+ requestAnimationFrame(function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ });
+ }
+ } else if (_quizTfMockActivePrev) {
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ _quizTfMockActivePrev = tfMockOn;
+ root.classList.toggle(
+ 'play-cyber-hud--stack-tower-canvas-hud',
+ !!(playBotsEnabled() && stackMini && isStack() && isStackTowerMissionUiMapPlay()),
+ );
+ root.classList.toggle('play-cyber-hud--ss-self-integrity-left', !!isSpaceShooterMissionHudActivePlay());
+ /** แถบ SCORE แนวนอน (mno9kb07) — ใช้รูปแบบเดียวกับ quiz_carry / พรีวิว editor */
+ root.classList.toggle(
+ 'play-cyber-hud--gauntlet-crown-strip',
+ !!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry())
+ && !(missionMockOn && getMissionMockHudSkinPlay() === 'gauntlet_crown')),
+ );
+ root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry()));
+ root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay());
+ /** Last Light mno9kb07: ไม่แสดง TIME / ซูมใต้เวลา — จบด้วยรันเวย์ ไม่ใช้นาฬิกา */
+ root.classList.toggle('play-cyber-hud--last-light-no-time', !!(on && isGauntletCrownHeistMapPlay()));
+ const timeLabelEl = root.querySelector('.play-cyber-time-label');
+ if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : '';
+ const timeVal = document.getElementById('play-cyber-time-val');
+ const timeSub = document.getElementById('play-cyber-time-sub');
+ const portrait = document.getElementById('play-cyber-portrait-img');
+ const statusEl = document.getElementById('play-cyber-self-status');
+ const hintEl = document.getElementById('play-cyber-hint');
+ const previewEl = document.getElementById('play-cyber-preview-line');
+
+ if (timeSub) {
+ const bbMegaLiveHud = isBalloonBoss() && isMegaVirusMissionShellMapPlay() && !isGauntletCrownPregameBlockingPlay();
+ timeSub.textContent = isQuizQuestionMissionHudActivePlay()
+ ? (playQuizPhaseLocal === 'read' ? 'READ · อ่านคำถาม'
+ : (playQuizPhaseLocal === 'answer' ? 'ANSWER · เดินไปโซน จริง / เท็จ'
+ : 'QUIZ MISSION · ประลองความรู้'))
+ : (isSpaceShooterMissionHudActivePlay()
+ ? 'SPACE SHOOTER · ARCADE — TIME (sec) · ภารกิจยิงยาน'
+ : (isStackTowerMissionHudActivePlay()
+ ? 'TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส'
+ : (isQuizCarry()
+ ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)'
+ : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
+ : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
+ : (isBalloonBoss() ? (bbMegaLiveHud ? '' : 'BALLOON BOSS · MEGA VIRUS')
+ : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))))));
+ timeSub.style.display = bbMegaLiveHud ? 'none' : '';
+ }
+ const zoomHintEl = document.getElementById('play-cyber-embed-zoom-hint');
+ if (zoomHintEl) {
+ const showEmbedZoom = !!(previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()
+ && !(isBalloonBoss() && isMegaVirusMissionShellMapPlay()));
+ if (showEmbedZoom) {
+ const zNum = Number(playEmbedUserZoomMul.toFixed(2));
+ zoomHintEl.textContent = '×' + String(zNum);
+ zoomHintEl.classList.remove('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'false');
+ } else {
+ zoomHintEl.textContent = '';
+ zoomHintEl.classList.add('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'true');
+ }
+ }
+ if (timeVal) {
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'live' || quizQuestionMissionPhase === 'ended') {
+ timeVal.textContent = '···';
+ } else if (!playQuizPhaseEndsAt) {
+ timeVal.textContent = '—';
+ } else {
+ timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)));
+ }
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase === 'live') {
+ const remSt = Math.max(0, Math.ceil(stackTowerMissionTimeLimitSecPlay() - stackTowerMissionElapsedSecPlay()));
+ timeVal.textContent = String(remSt);
+ } else if (stackTowerMissionPhase === 'howto' || stackTowerMissionPhase === 'countdown') {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isQuizCarry()) {
+ if (quizCarrySessionEnded) {
+ timeVal.textContent = '0';
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedCountdownEndAt > Date.now()) {
+ const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem);
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) {
+ const rem2 = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem2);
+ } else if (quizCarryPregameActive) {
+ timeVal.textContent = '···';
+ } else if (!quizCarryCurrent) {
+ timeVal.textContent = '—';
+ } else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000)));
+ } else if (quizCarryAnswerCloseAt > Date.now()) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000)));
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isGauntlet()) {
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '∞';
+ }
+ } else if (isSpaceShooter()) {
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) {
+ const limSs = spaceShooterTimeLimitSecPlay();
+ if (spaceShooterMissionPhase === 'howto' || spaceShooterMissionPhase === 'countdown') {
+ timeVal.textContent = String(limSs > 0 ? limSs : 0);
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else {
+ const remSs = spaceShooterRemainingSecPlay();
+ timeVal.textContent = remSs != null ? String(remSs) : '∞';
+ }
+ } else if (spaceShooterGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = spaceShooterRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isBalloonBoss()) {
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (balloonBossGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = balloonBossRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) {
+ timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0');
+ } else {
+ const rem = jumpSurviveRemainingSecMission();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else {
+ const t = jumpSurviveSessionStartMs > 0
+ ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
+ : 0;
+ timeVal.textContent = String(t);
+ }
+ }
+
+ if (portrait) {
+ applyCyberHudProfilePortraitImgWithRetry(portrait, 0);
+ }
+ if (statusEl) {
+ if (isSpaceShooterMissionHudActivePlay()) {
+ /* MG6 life ย้ายไป overlay เฉพาะ #mg6-life-hud (syncSpaceShooterLifeDomHudPlay) — คุมตำแหน่ง/ขนาดเท่า MG3 */
+ statusEl.textContent = '';
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ statusEl.textContent = 'QUIZ LINK · ประลองความรู้';
+ } else if (isStackTowerMissionHudActivePlay()) {
+ statusEl.textContent = 'DECRYPT UPLINK · TOWER';
+ } else if (isJumpSurviveMissionHudActivePlay()) {
+ statusEl.textContent = 'JUMPER LINK · SURVIVE';
+ } else if (isQuizCarry() && quizCarrySessionEnded) {
+ statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม';
+ } else if (isJumpSurvive() && jumpSurviveEliminated) {
+ statusEl.textContent = 'SPECTATOR · LINK SEVERED';
+ } else if (isBalloonBoss() && me.balloonBossEliminated) {
+ statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
+ } else if (isGauntletCrownHeistMapPlay()) {
+ statusEl.textContent = '';
+ } else {
+ statusEl.textContent = 'OPERATOR ONLINE';
+ }
+ }
+ if (hintEl) {
+ if (isQuizQuestionMissionHudActivePlay()) {
+ hintEl.textContent = '';
+ hintEl.setAttribute('aria-hidden', 'true');
+ } else {
+ hintEl.removeAttribute('aria-hidden');
+ if (isStackTowerMissionHudActivePlay()) {
+ hintEl.textContent = 'กด SPACE / ENTER หรือคลิกที่จอ = DROP · วางต่อเนื่องแม่น = โบนัส · ถอดรหัสให้ครบ 100% ภายในเวลา';
+ } else if (isQuizCarry()) {
+ hintEl.textContent = quizCarrySessionEnded
+ ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
+ : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
+ } else if (isJumpSurvive()) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)'
+ : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน';
+ } else {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'Spectate remaining nodes · Exit room to reset session'
+ : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
+ }
+ } else if (isSpaceShooter()) {
+ hintEl.textContent = spaceShooterGameEnded
+ ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
+ : 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
+ } else if (isBalloonBoss()) {
+ hintEl.textContent = balloonBossGameEnded
+ ? 'จบแล้ว · Session ended — see overlay'
+ : (me.balloonBossEliminated
+ ? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
+ : (isMegaVirusMissionShellMapPlay()
+ ? 'ลูกศรบนวงหมุนเอง — ยิงตามมุมลูกศร ณ ตอนกด Space · Ring spins · Space = fire along arrow'
+ : 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ'));
+ } else if (isGauntletCrownHeistMapPlay()) {
+ hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end';
+ } else {
+ hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
+ }
+ }
+ }
+ if (previewEl) {
+ if (playBotsEnabled() && mapData) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ const botTarget = playBotTargetHeadcount();
+ const simLabel = detectiveCaseFillBots ? 'CASE' : 'SIM';
+ previewEl.textContent = `${simLabel} · humans ${human} + bots ${bots} · target ${botTarget}`;
+ previewEl.classList.remove('is-hidden');
+ } else {
+ previewEl.textContent = '';
+ previewEl.classList.add('is-hidden');
+ }
+ }
+
+ syncQuizQuestionMissionCyberCenterHud();
+
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return;
+ /* mnptfts2 — ไม่แสดงแผง SCORE (เหลือ TIME + โปรไฟล์) */
+ if (isJumpSurviveMissionUiMapPlay()) {
+ ul.innerHTML = '';
+ ul.dataset.cyberSig = '';
+ const boardSkip = ul.closest('.play-cyber-scoreboard');
+ if (boardSkip) boardSkip.classList.remove('play-cyber-scoreboard--crown-strip');
+ ul.classList.remove('play-cyber-score-list--crown-strip');
+ return;
+ }
+ const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry();
+ const mg2MockHud = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ const board = ul.closest('.play-cyber-scoreboard');
+ if (board) {
+ board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud && !mg2MockHud);
+ board.classList.toggle('play-cyber-scoreboard--mg2-mock', !!mg2MockHud);
+ board.classList.toggle('mg2-score-bar', !!mg2MockHud);
+ }
+ ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud && !mg2MockHud);
+ ul.classList.toggle('play-cyber-score-list--mg2-mock', !!mg2MockHud);
+ const panelTitle = root.querySelector('.play-cyber-panel-title');
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (panelTitle && crownHead && crownImg) {
+ if (mg2MockHud) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ panelTitle.classList.add('is-hidden');
+ } else if (stripScoreHud) {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE :';
+ panelTitle.classList.remove('is-hidden');
+ } else {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE';
+ panelTitle.classList.remove('is-hidden');
+ }
+ }
+ const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay();
+ const quizMissionHud = isQuizQuestionMissionHudActivePlay();
+ const stackMissionHud = isStackTowerMissionHudActivePlay();
+ const jumpMissionLiveHud = isJumpSurviveMissionHudActivePlay();
+ const spaceMissionLiveHud = isSpaceShooterMissionHudActivePlay();
+ /** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp */
+ const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud;
+ /** mission mock HUD — layout แถว: [avatar | ชื่อบน/คะแนนล่าง] (meta column) */
+ const tfMockHud = isMissionMockHudPlay();
+ const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ const rows = [];
+ rows.push({
+ id: myId,
+ nickname: me.nickname || nick,
+ characterId: me.characterId || getPlayCharacterId(),
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : (nick || 'local'))),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(myId)
+ : (stackMissionHud
+ ? (playBotsEnabled() ? (me.stackPreviewHumanPts || 0) : stackTeamPts)
+ : (isGauntlet() ? (me.gauntletScore || 0)
+ : (isSpaceShooter() ? (me.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (me.balloonBossScore || 0)
+ : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(me.y),
+ eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated),
+ isMe: true,
+ });
+ others.forEach((o, id) => {
+ rows.push({
+ id,
+ nickname: o.nickname || id.slice(0, 8),
+ characterId: o.characterId,
+ playTint: o.playTint || resolvePlayTintForPeer(id),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(id)
+ : (stackMissionHud
+ ? (playBotsEnabled() && isPreviewBotId(id) ? (o.stackBotScore || 0) : stackTeamPts)
+ : (isGauntlet() ? (o.gauntletScore || 0)
+ : (isSpaceShooter() ? (o.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (o.balloonBossScore || 0)
+ : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(o.y),
+ eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
+ || !!(isBalloonBoss() && o.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated),
+ isMe: false,
+ });
+ });
+ let leaderId = null;
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ let mx = -1;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].score > mx) {
+ mx = rows[i].score;
+ leaderId = rows[i].id;
+ }
+ }
+ } else {
+ let best = Infinity;
+ for (let i = 0; i < rows.length; i++) {
+ if (!rows[i].eliminated && rows[i].y < best) {
+ best = rows[i].y;
+ leaderId = rows[i].id;
+ }
+ }
+ }
+ rows.sort((a, b) => {
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ if (b.score !== a.score) return b.score - a.score;
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th');
+ }
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return a.y - b.y;
+ });
+
+ /* Minigame-2 รองรับสูงสุด 6 ผู้เล่น (GAUNTLET_MAX_PLAYERS) — แถบคะแนนต้องโชว์ครบ 6 รวมตัวเรา
+ (เดิม cap 5 ตาม mockup ที่วาดตัวอย่าง 5 คน ทำให้ตัวเราคะแนนต่ำสุดถูกตัดออก) */
+ const cap = mg2MockHud ? 6 : (stripScoreHud ? (isQuizCarry() ? 8 : 6) : 8);
+ let hudRows = rows.slice(0, Math.min(cap, rows.length));
+ if (stripScoreHud) {
+ const mi = hudRows.findIndex((row) => row.isMe);
+ if (mi >= 0) {
+ const mine = hudRows.splice(mi, 1)[0];
+ hudRows.push(mine);
+ }
+ }
+ /* กันกระตุก: rebuild DOM เฉพาะตอนเนื้อหา (id/score/order/สี/ตกรอบ) เปลี่ยน — ไม่ลบ-สร้างทุกเฟรม */
+ const listSig = [
+ cyberQmMockHud ? 'qm' : '', mg2MockHud ? 'mg2' : '', stripScoreHud ? 'strip' : '', leaderId == null ? '' : String(leaderId),
+ hudRows.map(function (rw) {
+ const t = rw.playTint || {};
+ return String(rw.id) + ':' + String(rw.score) + ':' + (rw.eliminated ? 1 : 0)
+ + ':' + String(rw.characterId || '') + ':' + String(t.body || '') + ':' + String(t.head || '');
+ }).join(','),
+ ].join('|');
+ if (ul.dataset.cyberSig === listSig) {
+ const existImgs = ul.querySelectorAll('img.play-cyber-score-av');
+ if (existImgs.length === hudRows.length) {
+ for (let i = 0; i < existImgs.length; i++) {
+ if (!cyberHudScoreAvatarImgLooksReady(existImgs[i])) setCyberHudScoreAvatarImg(existImgs[i], hudRows[i]);
+ }
+ return;
+ }
+ }
+ ul.dataset.cyberSig = listSig;
+ ul.innerHTML = '';
+ for (let r = 0; r < hudRows.length; r++) {
+ const row = hudRows[r];
+ const li = document.createElement('li');
+ li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '')
+ + (cyberQmMockHud ? ' play-cyber-score-row--qm-mock' : '')
+ + (mg2MockHud ? ' play-cyber-score-row--mg2-mock' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-row--crown-strip' : '');
+ if (row.id != null) li.setAttribute('data-peer-id', String(row.id));
+ const av = document.createElement('img');
+ av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '')
+ + (mg2MockHud ? ' play-cyber-score-av--mg2' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-av--crown-strip' : '');
+ av.alt = '';
+ setCyberHudScoreAvatarImg(av, row);
+ const sc = document.createElement('span');
+ sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '')
+ + (mg2MockHud ? ' play-cyber-mg2-score' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-crown-strip-val' : '');
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ sc.textContent = String(row.score);
+ } else {
+ sc.textContent = row.eliminated ? '—' : String(r + 1);
+ }
+ if (cyberQmMockHud) {
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ sc.insertBefore(crown, sc.firstChild);
+ }
+ const qmLeft = document.createElement('div');
+ qmLeft.className = 'play-cyber-qm-left';
+ const avWrap = document.createElement('span');
+ avWrap.className = 'play-cyber-qm-av-wrap';
+ if (tfMockHud) {
+ /* mng8a80o: หน้าตัวละครต้องอยู่ "บน" กรอบ (กรอบกลางทึบ) + clip ให้อยู่ในขอบ */
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-qm-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ } else {
+ avWrap.appendChild(av);
+ }
+ const qmName = document.createElement('span');
+ qmName.className = 'play-cyber-qm-name';
+ qmName.textContent = String(row.nickname || '').trim().slice(0, 28);
+ qmLeft.appendChild(avWrap);
+ if (tfMockHud) {
+ /* mockup Minigame-1: avatar ซ้าย | (ชื่อบน / คะแนนล่าง) ในคอลัมน์ meta */
+ const qmMeta = document.createElement('div');
+ qmMeta.className = 'play-cyber-qm-meta';
+ qmMeta.appendChild(qmName);
+ qmMeta.appendChild(sc);
+ qmLeft.appendChild(qmMeta);
+ li.appendChild(qmLeft);
+ } else {
+ qmLeft.appendChild(qmName);
+ li.appendChild(qmLeft);
+ li.appendChild(sc);
+ }
+ } else if (mg2MockHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-mg2-player';
+ const name = document.createElement('div');
+ name.className = 'play-cyber-mg2-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 12)));
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-mg2-av-wrap';
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-mg2-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ cell.appendChild(name);
+ cell.appendChild(avWrap);
+ cell.appendChild(sc);
+ li.appendChild(cell);
+ } else if (stripScoreHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-crown-strip-cell';
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-crown-strip-av-wrap';
+ avWrap.appendChild(av);
+ const meta = document.createElement('div');
+ meta.className = 'play-cyber-crown-strip-meta';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-crown-strip-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 16)));
+ meta.appendChild(name);
+ meta.appendChild(sc);
+ cell.appendChild(avWrap);
+ cell.appendChild(meta);
+ li.appendChild(cell);
+ } else {
+ const mid = document.createElement('div');
+ mid.className = 'play-cyber-score-mid';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-score-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
+ const st = document.createElement('span');
+ st.className = 'play-cyber-score-state';
+ st.textContent = (isQuizCarry() || quizMissionHud)
+ ? ''
+ : (row.eliminated
+ ? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE'))
+ : (isBalloonBoss()
+ ? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
+ const ox = others.get(row.id);
+ return ox ? (ox.balloonBossBalloons | 0) : 0;
+ })())))
+ : 'LINK_OK'));
+ mid.appendChild(name);
+ mid.appendChild(st);
+ li.appendChild(av);
+ li.appendChild(mid);
+ li.appendChild(sc);
+ }
+ ul.appendChild(li);
+ }
+ }
+
+ function draw() {
+ try {
+ if (!mapData) {
+ document.documentElement.classList.remove('play-stack-tower-pixel-canvas');
+ return;
+ }
+ document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay());
+ } catch (e) { /* ignore */ }
+ if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ const w = mapData.width, h = mapData.height;
+
+ let zDraw = computePlayCameraZDrawPlay();
+ const stackCam = isStack() ? getStackCameraCentersPx() : null;
+ let camX = stackCam ? stackCam.px : me.x * tileSize;
+ let camY = stackCam ? stackCam.py : me.y * tileSize;
+ if (isJumpSurvive()) {
+ camX = jumpSurviveCamCenterX;
+ camY = jumpSurviveCamCenterY;
+ }
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize, mhPx = h * tileSize;
+ camX = mwPx * 0.5;
+ camY = mhPx * 0.5;
+ } else if (isQuizCarry()) {
+ const qc = getQuizCarryMapCameraWorldCenterPxPlay();
+ if (qc) {
+ camX = qc.cx;
+ camY = qc.cy;
+ }
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ camX = qmc.cx;
+ camY = qmc.cy;
+ }
+ } else if (isQuizBattle()) {
+ /* บอร์ดควิซแนวตั้ง (20×40) — ล็อกกล้องกลางแนวนอน (ไม่ไถลซ้าย/ขวาตามผู้เล่น) เลื่อนตามแนวตั้งเท่านั้น ตรง mock */
+ camX = (w * tileSize) / 2;
+ }
+ const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw);
+ if (gauntletGroupCam) {
+ camX = gauntletGroupCam.px;
+ camY = gauntletGroupCam.py;
+ }
+ /* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */
+ lastPlayZDrawForInput = zDraw;
+ const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
+ const halfW = canvas.width / (2 * zDraw);
+ const halfH = canvas.height / (2 * zDraw);
+
+ // world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
+ const worldMinX = camX - halfW;
+ const worldMaxX = camX + halfW;
+ const worldMinY = camY - halfH;
+ const worldMaxY = camY + halfH;
+
+ const mapWpx = w * tileSize, mapHpx = h * tileSize;
+ const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
+ const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
+ const timeMs = Date.now();
+
+ if (playScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawPlayScrollBgFullCanvas(canvas.width, canvas.height);
+ } else if (gauntletCrownRunwayBgDrawActivePlay()) {
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, {
+ worldMinX,
+ worldMaxX,
+ camX,
+ camY,
+ zDraw: zDraw,
+ mapWpx,
+ mapHpx,
+ });
+ } else if (stackTowerScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerScrollBgFullCanvas(canvas.width, canvas.height, zDraw);
+ } else if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
+ /* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, canvas.width, canvas.height);
+ const natW = mapBackgroundImg.naturalWidth;
+ const natH = mapBackgroundImg.naturalHeight;
+ const bgMinX = Math.max(0, worldMinX);
+ const bgMaxX = Math.min(mapWpx, worldMaxX);
+ const bgMinY = Math.max(0, worldMinY);
+ const bgMaxY = Math.min(mapHpx, worldMaxY);
+ if (bgMaxX > bgMinX && bgMaxY > bgMinY) {
+ const srcX = (bgMinX / mapWpx) * natW;
+ const srcY = (bgMinY / mapHpx) * natH;
+ const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
+ const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
+ const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
+ const destY = (bgMinY - camY) * zDraw + canvas.height / 2
+ - (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0);
+ const destW = (bgMaxX - bgMinX) * zDraw;
+ const destH = (bgMaxY - bgMinY) * zDraw;
+ if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
+ ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
+ }
+ }
+ } else {
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ if (isSpaceShooter()) {
+ ctx.save();
+ for (let s = 0; s < 120; s++) {
+ const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
+ const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
+ const br = (s % 4 === 0) ? 1.6 : 0.9;
+ ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, br, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ } else if (isBalloonBoss()) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
+ for (let col = 0; col < 24; col++) {
+ const x = (col + 0.5) * (canvas.width / 24);
+ for (let r = 0; r < 18; r++) {
+ const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
+ ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
+ ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
+ }
+ }
+ ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
+ ctx.beginPath();
+ ctx.moveTo(0, canvas.height);
+ const baseY = canvas.height * 0.88;
+ for (let k = 0; k <= 40; k++) {
+ const bx = (k / 40) * canvas.width;
+ const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
+ ctx.lineTo(bx, by);
+ }
+ ctx.lineTo(canvas.width, canvas.height);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ function worldToScreen(wx, wy) {
+ const sx = (wx - camX) * zDraw + canvas.width / 2;
+ const sy = (wy - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ return [sx, sy];
+ }
+
+ function drawGridImagePlacementsLayer() {
+ if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
+ for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
+ const sp = mapData.gridImagePlacements[gi];
+ const wx0 = sp.x * tileSize;
+ const wy0 = sp.y * tileSize;
+ const ww = sp.w * tileSize;
+ const wh = sp.h * tileSize;
+ if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
+ let gim = mapGridImageImgs[sp.i];
+ let carrySpriteAlpha = 1;
+ if (isQuizCarry()) {
+ const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp);
+ const meHolding = me && me.quizCarryHeld != null;
+ let carryVisualActive = false;
+ if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true;
+ else if (domOpt == null && onHub && meHolding) carryVisualActive = true;
+ if (carryVisualActive) {
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) gim = gh;
+ else carrySpriteAlpha = 0.32;
+ }
+ }
+ if (!gim || !gim.complete || !gim.naturalWidth) continue;
+ const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
+ const sy0 = (wy0 - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ const sw = ww * zDraw;
+ const sh = wh * zDraw;
+ ctx.save();
+ if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha;
+ ctx.beginPath();
+ ctx.rect(sx0, sy0, sw, sh);
+ ctx.clip();
+ ctx.drawImage(gim, sx0, sy0, sw, sh);
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
+ }
+ }
+
+ const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
+ const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
+ const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = isFrogger() ? getLane(y) : null;
+ let rowFill = null;
+ if (lane) {
+ if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
+ else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
+ else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
+ else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
+ } else if (isGauntlet()) {
+ rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
+ } else if (isStack()) {
+ rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
+ } else if (isJumpSurvive()) {
+ rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
+ } else if (isSpaceShooter()) {
+ rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
+ } else if (isBalloonBoss()) {
+ rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
+ } else if (isQuizBattle()) {
+ rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
+ }
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const ob = mapData.objects?.[y]?.[x] ?? 0;
+ if (ob === 1) {
+ ctx.fillStyle = 'rgba(65,72,104,0.92)';
+ ctx.fillRect(sx, sy, size, size);
+ ctx.strokeStyle = '#565f89';
+ ctx.strokeRect(sx, sy, size, size);
+ } else {
+ const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
+ if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
+ else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
+ else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
+ ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
+ ctx.fillRect(sx, sy, size, size);
+ }
+ }
+ }
+ }
+ }
+ drawGridImagePlacementsLayer();
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
+ if (isInter) {
+ ctx.fillStyle = 'rgba(158,206,106,0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(158,206,106,0.8)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (isQuiz()) {
+ const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isQuizQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
+ if (isQuizT) {
+ ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
+ if (isQuizF) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ }
+ if (isQuizCarry()) {
+ const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
+ if (hub) {
+ ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isCarryQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
+ if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
+ ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
+ /* ไฮไลต์เส้นทางบาง ๆ (โชว์เฉพาะตอนเปิดกริด) ให้เห็นถนนจาก design ทะลุ */
+ ctx.fillStyle = 'rgba(150, 195, 255, 0.12)';
+ ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
+ }
+ if (isQuizBattle() && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) {
+ const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x];
+ const comp = mapData.quizBattleDomeComp;
+ /* วาดหีบครั้งเดียวต่อโดม (ช่องบน-ซ้ายสุดของกลุ่ม comp) — กันวาดซ้อน */
+ const upSame = cid > 0 && comp && comp[y - 1] && comp[y - 1][x] === cid;
+ const leftSame = cid > 0 && comp && comp[y] && comp[y][x - 1] === cid;
+ if (!upSame && !leftSame) {
+ const passed = cid > 0 && quizBattlePassedComps.has(cid);
+ const wrong = passed && !quizBattleAnsweredComps.has(cid);
+ drawQuizBattleChest(ctx, sx + size / 2, sy + size / 2, size, passed, cid || 0, wrong);
+ }
+ }
+ if (isStack()) {
+ const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea;
+ if (sr && sr[y] && sr[y][x] === 1) {
+ ctx.fillStyle = 'rgba(125, 207, 255, 0.28)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (sl && sl[y] && sl[y][x] === 1) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.26)';
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)';
+ ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ /* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */
+ if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
+ const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
+ if (sv >= 1 && sv <= 6) {
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
+ ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
+ ctx.textAlign = 'left';
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && mapData.jumpSurvivePlatformArea) {
+ const pa = mapData.jumpSurvivePlatformArea;
+ const sc = jumpSurvivePlatformScrollPx;
+ const tsz = tileSize;
+ const period = h * tsz;
+ const visTop = worldMinY;
+ const visBot = worldMaxY;
+ const eps = 1e-6;
+ for (let py = 0; py < h; py++) {
+ for (let px = 0; px < w; px++) {
+ if (!pa[py] || pa[py][px] !== 1) continue;
+ const vIdx = jumpSurvivePlatformVariantIndexAtPlay(mapData, px, py);
+ if (px > 0 && pa[py][px - 1] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px - 1, py) === vIdx) {
+ continue;
+ }
+ let runLen = 1;
+ while (px + runLen < w && pa[py][px + runLen] === 1
+ && jumpSurvivePlatformVariantIndexAtPlay(mapData, px + runLen, py) === vIdx) runLen += 1;
+ const cfg = playJumpSurvivePlatformTiles[vIdx - 1] || { url: '', w: 0, h: 0 };
+ const jumpPlatU = normalizeGauntletAssetUrlForPlay(cfg.url || '');
+ const jumpPlatRec = jumpPlatU ? ensureGauntletAssetImage(jumpPlatU) : null;
+ const jumpPlatImg = jumpPlatRec && jumpPlatRec.ready && jumpPlatRec.img && jumpPlatRec.img.naturalWidth > 0 ? jumpPlatRec.img : null;
+ const tileWorldLeft = px * tsz;
+ const tileWorldRight = (px + runLen) * tsz;
+ if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
+ const rawTop = py * tsz + sc;
+ const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
+ const kMax = Math.floor((visBot - rawTop - eps) / period);
+ if (kMax < kMin) continue;
+ for (let kk = kMin; kk <= kMax; kk++) {
+ const platTop = rawTop + kk * period;
+ if (platTop + tsz <= visTop || platTop >= visBot) continue;
+ const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
+ const psz = tsz * zDraw;
+ const segScreenW = runLen * psz;
+ let dw;
+ let dh = cfg.h > 0 ? cfg.h * zDraw : psz - 4;
+ if (runLen > 1) {
+ dw = segScreenW;
+ } else {
+ dw = cfg.w > 0 ? cfg.w * zDraw : psz - 4;
+ const maxDim = psz * 4;
+ if (dw > maxDim) dw = maxDim;
+ }
+ const maxDh = psz * 4;
+ if (dh > maxDh) dh = maxDh;
+ const marginBot = 2;
+ const dx = runLen > 1 ? psx : psx + (psz - dw) / 2;
+ const dy = psy + psz - dh - marginBot;
+ if (jumpPlatImg) {
+ ctx.save();
+ try { ctx.imageSmoothingEnabled = true; } catch (e) { /* ignore */ }
+ ctx.drawImage(jumpPlatImg, dx, dy, dw, dh);
+ ctx.restore();
+ } else {
+ const fillW = runLen > 1 ? segScreenW - 6 : psz - 6;
+ ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
+ ctx.fillRect(psx + 3, psy + 3, fillW, psz - 6);
+ const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
+ grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
+ grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
+ grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(psx + 4, psy + 4, fillW - 2, psz - 8);
+ ctx.lineWidth = 1;
+ if (zDraw >= 1.15 && psz > 22) {
+ ctx.save();
+ ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const cx = psx + (runLen > 1 ? segScreenW * 0.5 : psz * 0.5);
+ ctx.fillText('DATA', cx, psy + psz * 0.38);
+ ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
+ ctx.fillText('/SEC/BLOCK', cx, psy + psz * 0.62);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && showGrid && mapData.jumpSurviveHazardArea) {
+ const ha = mapData.jumpSurviveHazardArea;
+ const tsz = tileSize;
+ for (let py = startTileY; py <= endTileY; py++) {
+ for (let px = startTileX; px <= endTileX; px++) {
+ if (!ha[py] || ha[py][px] !== 1) continue;
+ const [psx, psy] = worldToScreen(px * tsz, py * tsz);
+ const psz = tsz * zDraw;
+ ctx.fillStyle = 'rgba(200, 28, 55, 0.38)';
+ ctx.fillRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.strokeStyle = 'rgba(255, 90, 110, 0.82)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.lineWidth = 1;
+ }
+ }
+ }
+ if (showGrid) {
+ if (isFrogger() && mapData.lanes) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = getLane(y);
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
+ const positions = getVehiclePositions(lane, mapData.width, timeMs);
+ const isRoad = lane.type === 'road';
+ for (let i = 0; i < positions.length; i++) {
+ const vx = positions[i];
+ const wx = vx * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw * (isRoad ? 1.2 : 1.5);
+ ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
+ ctx.fillRect(sx, sy, size, size * 0.7);
+ ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
+ ctx.strokeRect(sx, sy, size, size * 0.7);
+ }
+ }
+ }
+ }
+
+ if (isQuizCarry()) {
+ drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
+ }
+
+ if (isStack() && stackMini) {
+ drawStackMinigame(ctx, worldToScreen, zDraw);
+ }
+
+ const gauntletObsAlpha = isGauntlet() ? gauntletCrownRunwayObstacleDrawAlphaPlay() : 1;
+ const gauntletObsDraw = (isGauntlet() && gauntletObsAlpha > 0.001)
+ ? getGauntletObsDrawPositionsAt(performance.now())
+ : [];
+ if (isGauntlet() && gauntletObsDraw.length) {
+ ctx.save();
+ ctx.globalAlpha = gauntletObsAlpha;
+ const stx = Math.max(0, Math.floor(worldMinX / tileSize));
+ const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const sty = Math.max(0, Math.floor(worldMinY / tileSize));
+ const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ for (let i = 0; i < gauntletObsDraw.length; i++) {
+ const o = gauntletObsDraw[i];
+ if (!o) continue;
+ if (o.kind === 'lane' && typeof o.y === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue;
+ const size = tileSize * zDraw;
+ const inner = Math.max(2, size - 4);
+ const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize);
+ const dx = sxC - inner / 2;
+ const dy = syCellBottom - inner;
+ const laneRec = pickGauntletLaneImageRec(o.id);
+ if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) {
+ try {
+ ctx.drawImage(laneRec.img, dx, dy, inner, inner);
+ } catch (e) {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else if (o.kind === 'laser' && typeof o.drawX === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2) continue;
+ /* ใช้โซนที่ editor กำหนด (gauntletLaserRowStart..End เช่น 4..18) — หัว/ท้าย emitter อยู่ตามนั้น */
+ const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h);
+ const wx0 = o.drawX * tileSize;
+ const wx1 = (o.drawX + 1) * tileSize;
+ const wy0 = laserRow0 * tileSize;
+ const wy1 = (laserRow1 + 1) * tileSize;
+ const [sx0, syTop] = worldToScreen(wx0, wy0);
+ const [sx1, syBot] = worldToScreen(wx1, wy1);
+ const rx = Math.min(sx0, sx1);
+ const rw = Math.max(2, Math.abs(sx1 - sx0));
+ const ry = Math.min(syTop, syBot);
+ const rh = Math.abs(syBot - syTop);
+ drawGauntletLaserColumnScreen(rx, ry, rw, rh);
+ }
+ }
+ ctx.restore();
+ }
+
+ /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */
+ function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) {
+ const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl);
+ const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null;
+ const raw = String(signText || '').trim();
+ if (!raw && !imgUrlSan && !gridHeldDraw) return;
+ ctx.save();
+ const heldPs = quizCarryPlaqueMapScaleClampedPlay();
+ const bodyH = Math.max(18, feetSy - spriteTopY);
+ const midBodyY = spriteTopY + bodyH * 0.48;
+ const bodyCx = sxFeet;
+ const halfW = Math.max(10, halfSpriteW || 0);
+ const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5));
+ const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs));
+
+ const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs));
+ ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`;
+ const padX = 12;
+ const padY = 9;
+ const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2));
+ let lines;
+ if (!raw) {
+ lines = [];
+ } else {
+ lines = canvasWordWrapLines(ctx, raw, maxInner);
+ if (!lines.length) lines = [raw];
+ if (lines.length > 3) lines = lines.slice(0, 3);
+ for (let i = 0; i < lines.length; i++) {
+ let s = lines[i];
+ if (ctx.measureText(s).width <= maxInner) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1);
+ lines[i] = s + '…';
+ }
+ }
+ let maxLineW = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lw = ctx.measureText(lines[i]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const lineH = fontPx * 1.22;
+ let w;
+ let h;
+ if (!lines.length && (imgUrlSan || gridHeldDraw)) {
+ w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2)));
+ h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2));
+ } else {
+ w = Math.ceil(Math.max(maxLineW, 56) + padX * 2);
+ h = Math.ceil(lines.length * lineH + padY * 2);
+ w = Math.min(maxPlaqueW, Math.max(96, w));
+ h = Math.max(40, h);
+ }
+
+ let signX = bodyCx - w / 2;
+ let signY = midBodyY - h / 2;
+
+ if (signX + w > canvas.width - 6) signX = canvas.width - w - 6;
+ if (signX < 6) signX = 6;
+ if (signY < 6) signY = 6;
+ if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
+
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw });
+ ctx.restore();
+ }
+
+ function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload, crownPenaltyFxUntil) {
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const air = Number(gauntletAirTicks) || 0;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ /* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const boxW = tileSize * zDraw * Math.max(cw, 1) * 1.15;
+ const boxH = tileSize * zDraw * Math.max(ch, 1) * 1.35;
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const dir = direction || 'down';
+ const charImg = resolvePlayDrawCharSource(characterId, dir, timeMs, isWalking, playTint, !!isMe);
+ /* รองรับทั้ง และ canvas จากย้อมสี — กล่อง W×H ตาม footprint; อนุญาต upscale เลเยอร์เล็ก (ห้ามจำกัด scale≤1 ไม่งั้นตัวหดเป็นจุด) */
+ let iw = 0, ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth > 0 && charImg.naturalHeight > 0) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ let halfSpriteW = r;
+ let spriteTopY = sy - 2 * r;
+ if (iw > 0 && ih > 0) {
+ const scale = Math.min(boxW / iw, boxH / ih);
+ let drawW = iw * scale;
+ let drawH = ih * scale;
+ if (isQuiz()) {
+ const minPx = Math.max(28, r * 1.85);
+ if (drawW < minPx || drawH < minPx) {
+ const up = Math.max(minPx / Math.max(drawW, 0.01), minPx / Math.max(drawH, 0.01));
+ drawW *= up;
+ drawH *= up;
+ }
+ }
+ halfSpriteW = drawW / 2;
+ spriteTopY = sy - drawH;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
+ } else {
+ const cy = sy - r;
+ ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#c0caf5';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ spriteTopY = cy - r;
+ }
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ const nameBase = name || '';
+ /* gauntlet (Last Light) live: ชื่อ/คะแนน/-10 ใช้ DOM overlay (syncGauntletNameDom) — ไม่วาดบน canvas */
+ /* crown map (Minigame-2): ไม่วาดชื่อ/คะแนน/-10 ใต้ตัวละครบน canvas "ทุกเฟส" (ตรง mock — โชว์เฉพาะแถบคะแนนบน)
+ เดิมเช็คเฉพาะ phase==='live' → ตอน summary/howto canvas วาดชื่อกลับมา ทับแถบคะแนน (ดูเหมือน avatar ซ้อน) */
+ const gntDomLabel = isGauntletCrownHeistMapPlay();
+ const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
+ ? (nameBase + ' · ' + gauntletScoreLabel)
+ : nameBase;
+ if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10);
+ if (!gntDomLabel
+ && isGauntletCrownHeistMapPlay()
+ && gauntletCrownPregamePhase === 'live'
+ && typeof crownPenaltyFxUntil === 'number'
+ && crownPenaltyFxUntil > timeMs) {
+ const penImg = ensureGauntletCrownScorePenaltyImgPlay();
+ if (penImg && penImg.complete && penImg.naturalWidth > 0) {
+ const ref = Math.max(18, r * 2);
+ const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1));
+ const dw = penImg.naturalWidth * scale;
+ const dh = penImg.naturalHeight * scale;
+ ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh);
+ }
+ }
+ if (isQuizCarry() && quizCarrySignPayload) {
+ drawQuizCarryHeldSignBoard(
+ sx, sy, halfSpriteW, spriteTopY,
+ quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
+ quizCarrySignPayload.imageUrl,
+ quizCarrySignPayload.imageElement,
+ );
+ }
+ }
+ function quizCarrySignForEntity(ent) {
+ if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
+ const idx = ent.quizCarryHeld;
+ const ch = quizCarryCurrent.choices;
+ if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
+ const t = String(ch[idx] != null ? ch[idx] : '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
+ const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
+ if (!t && !imgUrl && !gridHeldEl) return null;
+ return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
+ }
+ const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ function peerVisualOffset(id) {
+ let h = 0;
+ for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
+ return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
+ }
+ function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
+ if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
+ if (!ent || ent.gauntletEliminated) return true;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ax = safeX(ent.x) + (offAx || 0);
+ const ay = safeY(ent.y) + (offAy || 0);
+ const airTicks = isMeFlag ? (meGauntletJumpTicks || 0) : (Number(ent.gauntletJumpTicks) || 0);
+ const airVis = isMeFlag ? (meGauntletJumpVis || 0) : (Number(ent.gauntletJumpVis) || 0);
+ const air = airVis > 0.08 ? airVis : airTicks;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const pad = Math.max(28, r * 0.4);
+ if (sx + r + pad < 0 || sx - r - pad > canvas.width) return true;
+ if (sy + 52 < 0 || sy - r * 4 - pad > canvas.height) return true;
+ return false;
+ }
+ const { ch: sortCh } = getCharacterFootprintWH(mapData);
+ /** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */
+ function avatarDrawDepth(ent) {
+ return Math.floor(safeY(ent.y)) + sortCh;
+ }
+ if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
+ let mt = me.playTint;
+ if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') {
+ mt = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')));
+ me.playTint = mt;
+ }
+ const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
+ const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const meWalking = isFrogger()
+ ? false
+ : (isGauntlet()
+ ? ((crownRunStripLive && !me.gauntletEliminated) || meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08)
+ : !!me.isWalking);
+ const drawRows = [];
+ others.forEach((o, id) => {
+ if (!o) return;
+ drawRows.push({ isMe: false, id, o });
+ });
+ drawRows.push({ isMe: true, id: '__me', o: me });
+ drawRows.sort((a, b) => {
+ const da = avatarDrawDepth(a.o);
+ const db = avatarDrawDepth(b.o);
+ if (da !== db) return da - db;
+ const xa = Math.floor(safeX(a.o.x) * 4);
+ const xb = Math.floor(safeX(b.o.x) * 4);
+ if (xa !== xb) return xa - xb;
+ const sa = a.isMe ? '\uFFFFme' : String(a.id);
+ const sb = b.isMe ? '\uFFFFme' : String(b.id);
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
+ });
+ drawRows.forEach((row) => {
+ if (!row.isMe) {
+ const id = row.id;
+ const o = row.o;
+ const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
+ const otherWalk = isGauntlet()
+ ? ((crownRunStripLive && !o.gauntletEliminated) || (o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
+ : (isPreviewBotId(id)
+ ? !!o.botIsWalking
+ : !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
+ const ot = o.playTint || resolvePlayTintForPeer(id);
+ const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction;
+ const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
+ const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
+ const axO = safeX(o.x) + off.ax;
+ const ayO = safeY(o.y) + off.ay;
+ /* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */
+ const ghostActiveO = isQuizWrongGhostActiveForEnt(id);
+ if (!ghostActiveO && playQuizAvatarHideWhileOverlappingAnswerZone(o, id, axO, ayO)) return;
+ if (botOut) ctx.save();
+ if (botOut) ctx.globalAlpha = 0.4;
+ /* ตอบผิดในแมป mng8a80o → แทน avatar ด้วยผีเลย */
+ if (ghostActiveO) {
+ const ga = _quizGhostScreenAnchorPlay(axO, ayO, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(ga.sx, ga.sy, ga.sizeH, id, peerName);
+ } else {
+ drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(o),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (botOut) ctx.restore();
+ } else {
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
+ const axMe = safeX(me.x);
+ const ayMe = safeY(me.y);
+ const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId);
+ if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
+ const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
+ if (ghostActiveMe) {
+ const gam = _quizGhostScreenAnchorPlay(axMe, ayMe, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(gam.sx, gam.sy, gam.sizeH, myId, me.nickname + meTag);
+ } else {
+ drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(me),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
+ }
+ });
+ /* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
+ try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
+ }
+ /* ไอคอนคำถามพิเศษในฉาก (วาดทับ map/ผู้เล่น) + ตรวจการเดินชน */
+ if (specialQuizIcon) {
+ drawSpecialQuizIconWorld(worldToScreen, zDraw);
+ checkSpecialQuizCollision();
+ }
+ if (isFrogger()) {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
+ }
+ if (isJumpSurvive() && !useCyberPlayHud()) {
+ ctx.fillStyle = 'rgba(148, 226, 213, 0.95)';
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'left';
+ if (jumpSurviveEliminated) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.95)';
+ ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22);
+ } else {
+ ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22);
+ }
+ }
+ if (isGauntlet() && !useCyberPlayHud()) {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ if (isGauntletCrownHeistMapPlay()) {
+ /* Minigame-2 (Last Light): ใช้ cyber HUD + DOM overlay แล้ว — ไม่วาด debug text (คะแนน · ชน -10 · ขอบซ้ายตาย) บน canvas
+ (เดิมโผล่มุมซ้ายบนทับ SCORE label เวลา useCyberPlayHud() = false ใน preview embed) */
+ } else {
+ ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42);
+ }
+ ctx.fillStyle = '#e0af68';
+ if (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) {
+ if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ const clock = `${mm}:${String(ss).padStart(2, '0')}`;
+ ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
+ } else {
+ ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
+ }
+ }
+ }
+ if (isStack() && stackMini) {
+ if (playBotsEnabled()) {
+ if (!playHideTopChrome()) {
+ ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
+ ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
+ }
+ /* Tower mnn93hpi: แผงขวา (ชีวิตทีม + อวตาร์) วาดบน canvas — ต้องวาดแม้ซ่อนแถบห้องบน */
+ if (!playHideTopChrome() || (isStackTowerMissionUiMapPlay() && useCyberPlayHud())) {
+ drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
+ }
+ } else {
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = 'bold 15px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
+ }
+ }
+ if (isQuiz() || isQuizCarry() || isQuizBattle()) {
+ syncPlayQuizMapPanel();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+ /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */
+ syncQuizCarryTimeupDeskLayerPosition();
+ if (playBotsEnabled() && mapData && !useCyberPlayHud()) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = 'rgba(26,27,38,0.78)';
+ const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
+ ctx.fillRect(6, canvas.height - 38, hudW, 30);
+ ctx.fillStyle = '#a9b1d6';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'left';
+ const botTarget = playBotTargetHeadcount();
+ let line = detectiveCaseFillBots
+ ? ('คดีสืบสวน: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)')
+ : ('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)');
+ if (isStack()) {
+ line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
+ }
+ ctx.fillText(line, 12, canvas.height - 18);
+ ctx.restore();
+ }
+ if (isSpaceShooter()) {
+ drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isBalloonBoss()) {
+ drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isQuizCarry()) {
+ drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (quizCarryEmbedMissionFlowActive()
+ && (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) {
+ syncQuizCarryEmbedCountdownLayout();
+ }
+ drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs);
+ syncPlayCyberHud();
+ syncQuizQuestionMissionJoystickPlay();
+ syncQuizCarryGrabButton();
+ syncGauntletCrownJumpButton();
+ syncStackTowerDropButtonPlay();
+ syncStackTowerMissionDomHudPlay();
+ syncSpaceShooterLifeDomHudPlay();
+ syncQuizBattleHudPlay();
+ }
+
+ document.addEventListener('keydown', (e) => {
+ /* Test Mode shortcut: Ctrl+Q = ตอบถูกใน special quiz (server เติมคำตอบให้) */
+ if (e.ctrlKey && (e.code === 'KeyQ' || e.key === 'q' || e.key === 'Q')) {
+ if (specialQuizActiveQuestion && !specialQuizAnswered && __isTestModeOnPlay()) {
+ e.preventDefault();
+ specialQuizAnswered = true;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '[Test] ส่งคำตอบที่ถูกอัตโนมัติ — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) Array.prototype.forEach.call(choicesEl.children, function (b) { b.disabled = true; });
+ try { socket.emit('special-quiz-answer', { debugCorrect: true }, function () {}); } catch (eQ) { /* ignore */ }
+ return;
+ }
+ }
+ if (isMovementKey(e.code) && isChatFocused()) return;
+ if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) {
+ if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, playEmbedUserZoomMul / PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ if (
+ e.code === 'BracketRight'
+ || e.code === 'NumpadAdd'
+ || (e.code === 'Equal' && e.shiftKey)
+ ) {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ }
+ if (isSpaceShooter() && mapData && !isChatFocused() && isSpaceShooterMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isQuiz() && mapData && !isChatFocused() && isQuizQuestionMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isStack() && mapData && !isChatFocused() && isStackTowerMissionPregameBlockingPlay()) {
+ if (['Space', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+ if (isStack() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'Enter' || e.code === 'NumpadEnter') {
+ e.preventDefault();
+ tryHumanStackDrop();
+ return;
+ }
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isGauntlet() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ tryRequestGauntletJumpPlay();
+ return;
+ }
+ if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isJumpSurvive() && mapData && !isChatFocused()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (jumpSurviveEliminated) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ if (!e.repeat) jumpSurviveJumpQueued = true;
+ return;
+ }
+ if (e.code === 'ArrowDown' || e.code === 'KeyS') {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isQuizBattle() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyE') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
+ return;
+ }
+ }
+ if (isQuizCarry() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyF') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ return;
+ }
+ }
+ keys[e.code] = true;
+ keys[e.key] = true;
+ if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
+ if (isFrogger() && mapData && !isChatFocused()) {
+ const now = Date.now();
+ if (now - lastFroggerKey < 280) return;
+ let dx = 0, dy = 0;
+ if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
+ else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
+ else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
+ else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
+ if (dx !== 0 || dy !== 0) {
+ const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
+ if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
+ const row = mapData.objects && mapData.objects[ny];
+ if (!row || row[nx] !== 1) {
+ me.x = nx; me.y = ny;
+ lastFroggerKey = now;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ const lane = getLane(Math.floor(me.y));
+ if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
+ else if (checkFroggerCollision()) respawnFrogger();
+ }
+ }
+ }
+ }
+ });
+ document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
+
+ let lastSend = 0;
+ function tick() {
+ if (!mapData) { requestAnimationFrame(tick); return; }
+ /* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้
+ ดันเวลาทุกตัวไปข้างหน้าเท่า delta ทุกเฟรม → countdown ที่แสดงหยุดนิ่งจริง */
+ if (specialQuizFreeze) {
+ tickSpecialQuizFreezeShift();
+ if (me) me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isStack()) {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ const nowStBg = performance.now();
+ const dtStBg = Math.min(0.06, (nowStBg - lastStackTowerScrollBgTickMs) / 1000);
+ lastStackTowerScrollBgTickMs = nowStBg;
+ tickStackTowerScrollBg(dtStBg);
+ if (isStackTowerMissionPregameBlockingPlay()) {
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ stackTickFrame();
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isJumpSurvive()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ jumpSurviveTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isSpaceShooter()) {
+ if (isSpaceShooterMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ spaceShooterTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isBalloonBoss()) {
+ balloonBossTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isFrogger()) {
+ if (checkFroggerCollision()) respawnFrogger();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isGauntlet()) {
+ {
+ const nowBg = performance.now();
+ const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016);
+ lastGauntletCrownRunwayBgTickMs = nowBg;
+ tickGauntletCrownRunwayBgPlay(dtBg);
+ }
+ if (isGauntletCrownPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()) || meGauntletJumpTicks > 0;
+ const stepGauntletJumpVis = (vis, tgt) => {
+ const t = Number(tgt) || 0;
+ let v = Number(vis) || 0;
+ const d = t - v;
+ const k = d < 0 ? 0.68 : 0.4;
+ return v + d * k;
+ };
+ meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
+ const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
+ me.x = mp.nx;
+ me.y = mp.ny;
+ others.forEach((o) => {
+ const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
+ o.x = op.nx;
+ o.y = op.ny;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
+ o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
+ });
+ clampPlayEntityFootprintToMap(me, mapData);
+ others.forEach((o) => {
+ if (o) clampPlayEntityFootprintToMap(o, mapData);
+ });
+ let zGa = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zGa *= playEmbedUserZoomMul;
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa);
+ if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa);
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
+ others.forEach((o, id) => {
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * LERP;
+ if (o.ty != null) o.y += (o.ty - o.y) * LERP;
+ });
+ tickQuizCarryEmbedCountdown();
+ tickQuizCarryRoundTimers();
+ updateQuizCarryCarryPhaseHud();
+ stepPreviewBots();
+ if (isChatFocused()) {
+ me.isWalking = false;
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) {
+ me.isWalking = false;
+ playPath = [];
+ enforceQuizBattleLaneOnMePlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let accX = 0, accY = 0;
+ let usePath = false;
+ const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD'];
+ if (playPath.length > 0 && keyPressed) playPath = [];
+ if (playPath.length > 0) {
+ const way = playPath[0];
+ const dx = way.x - me.x, dy = way.y - me.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ playPath.shift();
+ /* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */
+ while (playPath.length > 0) {
+ const w2 = playPath[0];
+ const ux = w2.x - me.x, uy = w2.y - me.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ playPath.shift();
+ }
+ if (playPath.length === 0) {
+ me.isWalking = false;
+ clampPlayEntityFootprintToMap(me, mapData);
+ 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;
+ }
+ usePath = true;
+ const next = playPath[0];
+ accX = next.x - me.x;
+ accY = next.y - me.y;
+ } else {
+ usePath = true;
+ accX = dx;
+ accY = dy;
+ }
+ if (usePath && (accX !== 0 || accY !== 0)) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ if (!usePath) {
+ let kx = 0;
+ let ky = 0;
+ if (keys['ArrowLeft'] || keys['KeyA']) kx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) kx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) ky -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) ky += 1;
+ let jx = 0;
+ let jy = 0;
+ if (isQuizQuestionMissionHudActivePlay()) {
+ jx = quizQuestionMissionJoyVecX;
+ jy = quizQuestionMissionJoyVecY;
+ }
+ accX = kx + jx;
+ accY = ky + jy;
+ if (accX !== 0 || accY !== 0) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ const preWalkX = me.x, preWalkY = me.y;
+ if (accX !== 0 || accY !== 0) {
+ const len = Math.sqrt(accX * accX + accY * accY) || 1;
+ const step = Math.min(moveSpeedTilesThisFrameForWalk(), len);
+ const tox = me.x + (accX / len) * step;
+ const toy = me.y + (accY / len) * step;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(me.x, me.y, tox, toy, (nx, ny, ox, oy) => canWalkLikeLobby(nx, ny, ox, oy));
+ me.x = moved.x;
+ me.y = moved.y;
+ } else if (canWalkLikeLobby(tox, toy, me.x, me.y)) {
+ me.x = tox;
+ me.y = toy;
+ } else {
+ if (canWalkLikeLobby(tox, me.y, me.x, me.y)) {
+ me.x = tox;
+ } else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) {
+ me.y = toy;
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(me, mapData);
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5;
+ 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);
+ }
+
+ const chatForm = document.getElementById('chat-form');
+ if (chatForm) {
+ chatForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const input = document.getElementById('chat-input');
+ const text = (input && input.value || '').trim();
+ if (text) { socket.emit('chat', text); if (input) input.value = ''; }
+ });
+ }
+ document.getElementById('btn-leave').addEventListener('click', () => {
+ socket.emit('leave-space');
+ const mid = params.get('map');
+ if (previewMode && mid) {
+ var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : '');
+ window.location.replace(BASE + '/editor.html' + edQ);
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ });
+ (function wireQuizBattleHudButtons() {
+ /* EXIT → ออกห้อง กลับหน้าเลือกห้อง Quiz Battle */
+ const exitBtn = document.getElementById('qb-hud-exit');
+ if (exitBtn) {
+ exitBtn.addEventListener('click', () => {
+ try { saveQuizBattleProgress(); } catch (e) { /* ignore */ }
+ try { socket.emit('leave-space'); } catch (e) { /* ignore */ }
+ let href = '../Quiz-Battle/';
+ try { if (typeof appPath === 'function') href = appPath('/Quiz-Battle/'); } catch (e2) { /* ignore */ }
+ window.location.href = href;
+ });
+ }
+ /* MY RESULT → popup ผลตอบของฉัน */
+ const myResultBtn = document.getElementById('qb-hud-myresult');
+ if (myResultBtn) myResultBtn.addEventListener('click', function () { openQuizBattleMyResult(); });
+ /* RANKING → popup อันดับ (global leaderboard) */
+ const rankingBtn = document.getElementById('qb-hud-ranking');
+ if (rankingBtn) rankingBtn.addEventListener('click', function () { openQuizBattleRanking(); });
+ /* ZOOM เข้า/ออก ในฉาก */
+ const applyQbZoom = function (mul) {
+ quizBattleUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * mul));
+ try { draw(); } catch (e) { /* ignore */ }
+ };
+ const zoomInBtn = document.getElementById('qb-hud-zoom-in');
+ if (zoomInBtn) zoomInBtn.addEventListener('click', function () { applyQbZoom(1.2); });
+ const zoomOutBtn = document.getElementById('qb-hud-zoom-out');
+ if (zoomOutBtn) zoomOutBtn.addEventListener('click', function () { applyQbZoom(1 / 1.2); });
+ /* ปิด popup: ปุ่ม X / backdrop (data-qb-close) */
+ ['qb-myresult-popup', 'qb-ranking-popup', 'qb-completed-popup'].forEach(function (pid) {
+ const pop = document.getElementById(pid);
+ if (!pop) return;
+ pop.addEventListener('click', function (e) {
+ const t = e.target;
+ if (t && t.getAttribute && t.getAttribute('data-qb-close')) {
+ closeQuizBattlePopup(t.getAttribute('data-qb-close'));
+ }
+ });
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') { closeQuizBattlePopup('myresult'); closeQuizBattlePopup('ranking'); closeQuizBattlePopup('completed'); }
+ });
+ })();
+ (function wireQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (!ov) return;
+ ov.addEventListener('click', (e) => {
+ const t = e.target;
+ if (!t) return;
+ const choice = t.closest ? t.closest('.quiz-battle-choice') : null;
+ if (choice) {
+ const idx = parseInt(choice.getAttribute('data-idx'), 10);
+ if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx);
+ return;
+ }
+ if (t.id === 'quiz-battle-mcq-close') {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+ });
+ })();
+ (function wireQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ if (!isQuizCarry() || !mapData || isChatFocused()) return;
+ if (!btn.classList.contains('quiz-carry-grab-btn--active')) return;
+ ev.preventDefault();
+ if (myId == null) return;
+ tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ });
+ })();
+
+ (function wireGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (isChatFocused()) return;
+ /* MG5 jumper: click = queue jump (mirror keyboard Space/W/↑) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) { jumpSurviveJumpQueued = true; return; }
+ if (!mapData || !isGauntlet()) return;
+ tryRequestGauntletJumpPlay();
+ });
+ })();
+
+ (function wireStackTowerDropButton() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (!mapData || !isStack() || isChatFocused()) return;
+ if (!btn.classList.contains('stack-tower-drop-btn--active')) return;
+ tryHumanStackDrop();
+ });
+ })();
+
+ (function wireQuizQuestionMissionJoystick() {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (!base) return;
+ if (bg) {
+ bg.src = questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = 'question';
+ bg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ if (knobImg) {
+ knobImg.src = questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = 'question';
+ knobImg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ function endJoy(e) {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ try {
+ base.releasePointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ base.addEventListener('pointerdown', (e) => {
+ if (!(isQuizQuestionMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()) || isChatFocused()) return;
+ if (e.button != null && e.button !== 0) return;
+ e.preventDefault();
+ try {
+ base.setPointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = e.pointerId;
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointermove', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ e.preventDefault();
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointerup', endJoy);
+ base.addEventListener('pointercancel', endJoy);
+ base.addEventListener('lostpointercapture', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ });
+ })();
+
+ (function wireGauntletCrownHowtoPrimary() {
+ function onHowtoPrimaryClick() {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ if (!(humans.length === 1 || isMePlayHost())) return;
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'howto') return;
+ const humansSs = quizCarryPregameHumanIds();
+ const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSs === 1) {
+ if (!(humansSs.length === 1 || isMePlayHost())) return;
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSs = String(myId);
+ const nextSs = !gauntletCrownLobbyReadyMap[sidSs];
+ gauntletCrownLobbyReadyMap[sidSs] = nextSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSs });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySs = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySs) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSs = String(myId);
+ const nextHostSs = !gauntletCrownLobbyReadyMap[sidHostSs];
+ gauntletCrownLobbyReadyMap[sidHostSs] = nextHostSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSs });
+ return;
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase !== 'howto') return;
+ const humansSt = quizCarryPregameHumanIds();
+ const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSt === 1) {
+ if (!(humansSt.length === 1 || isMePlayHost())) return;
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSt = String(myId);
+ const nextSt = !gauntletCrownLobbyReadyMap[sidSt];
+ gauntletCrownLobbyReadyMap[sidSt] = nextSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySt) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSt = String(myId);
+ const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt];
+ gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt });
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownPregamePhase !== 'howto') return;
+ if (myId == null || !isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ if (humans.length === 1 && isMePlayHost()) {
+ beginGauntletCrownCountdownThenRun();
+ return;
+ }
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
+ if (!r || !r.ok) { /* ignore */ }
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateGauntletCrownHowtoHud();
+ socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ }
+ ['btn-gch-ready', 'btn-gch-ready-mg2'].forEach((id) => {
+ const btn = document.getElementById(id);
+ if (btn) btn.addEventListener('click', onHowtoPrimaryClick);
+ });
+ })();
+
+ (function wireQuizCarryPregameOverlay() {
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ if (!primary) return;
+ primary.addEventListener('click', () => {
+ if (!quizCarryPregameActive || myId == null) return;
+ if (!isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ if (humansReady) {
+ /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */
+ if (humans.length === 1 && isMePlayHost()) {
+ endQuizCarryEmbedPregameAndStart();
+ return;
+ }
+ socket.emit('quiz-carry-lobby-start', {}, (r) => {
+ if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false);
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !quizCarryLobbyReadyMap[sid];
+ quizCarryLobbyReadyMap[sid] = next;
+ updateQuizCarryPregameHud();
+ socket.emit('quiz-carry-lobby-ready', { ready: next });
+ });
+ })();
+ window.addEventListener('beforeunload', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } socket.emit('leave-space'); });
+ window.addEventListener('resize', () => {
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ });
+ /** แคนวาส flex ขยายทีหลัง (จอใหญ่ / editor iframe) — ResizeObserver จับได้ดีกว่า resize อย่างเดียว */
+ (function setupPlayCanvasStackResizeObserver() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack || typeof ResizeObserver === 'undefined') return;
+ let roRaf = null;
+ const run = () => {
+ roRaf = null;
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ };
+ const ro = new ResizeObserver(() => {
+ if (roRaf) cancelAnimationFrame(roRaf);
+ roRaf = requestAnimationFrame(run);
+ });
+ ro.observe(stack);
+ const wrap = stack.closest('.game-wrap');
+ if (wrap) ro.observe(wrap);
+ const stageEl = document.getElementById('play-canvas-stage');
+ if (stageEl) ro.observe(stageEl);
+ })();
+})();
diff --git a/www/html/Game/public/js/play.js.bak-mg4hud-105432 b/www/html/Game/public/js/play.js.bak-mg4hud-105432
new file mode 100644
index 0000000..8b951ad
--- /dev/null
+++ b/www/html/Game/public/js/play.js.bak-mg4hud-105432
@@ -0,0 +1,23218 @@
+(function () {
+ const BASE = '/Game';
+ function playDesignMainMenuHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') {
+ return globalThis.designMainMenuHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Menu/';
+ }
+ function playDesignMainLobbyHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') {
+ return globalThis.designMainLobbyHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Lobby/';
+ }
+ /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */
+ const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/';
+ /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */
+ const QUIZ_CARRY_MAP_FEEDBACK_IMG = {
+ correct: BASE + '/img/quiz-carry/icon-Correct.png',
+ incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png',
+ scorePlus: BASE + '/img/quiz-carry/score+10.png',
+ };
+ const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400;
+ const params = new URLSearchParams(window.location.search);
+ const spaceId = params.get('space');
+ function readStoredDisplayName() {
+ try {
+ return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim();
+ } catch (e) { return ''; }
+ }
+ let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น';
+ const previewMode = params.get('preview') === '1';
+ const specialQuizForceDebug = params.get('sqForce') === '1';
+ const forceDefaultCharacter = params.get('defaultChar') === '1';
+ const editorEmbedReturn = params.get('editorEmbed') === '1';
+ if (previewMode && editorEmbedReturn) {
+ try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ }
+ }
+ const playMapIdFromQuery = (params.get('map') || '').trim();
+ /** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
+ let playSessionMapId = playMapIdFromQuery;
+ /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
+ const quizCarryMissionSummaryMapId = 'mnorwqx1';
+ const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
+ /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
+ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
+ /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
+ const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
+ /** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */
+ const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
+ /** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */
+ const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o';
+ /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */
+ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
+ /** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */
+ const STACK_TOWER_FIXED_RENDER_W = 1920;
+ const STACK_TOWER_FIXED_RENDER_H = 1080;
+ /** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */
+ const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280;
+ /** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */
+ const STACK_TOWER_ROPE_DRAW_T1 = 1.0;
+ /** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */
+ const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25;
+ /** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */
+ const STACK_TOWER_POST50_PROGRESS_THRESH = 60;
+ const STACK_TOWER_POST50_ZOOM_MUL = 1.1;
+ /** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */
+ const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1;
+ const STACK_TOWER_POST50_ANIM_MS = 680;
+ /** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */
+ const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5;
+ /** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */
+ const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25;
+ /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */
+ const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
+ /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */
+ const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9;
+ /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */
+ const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14;
+ const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3;
+ /** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */
+ const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38;
+ /** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */
+ const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0;
+ /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */
+ const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62;
+ /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */
+ const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314;
+ /** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */
+ const STACK_TOWER_POST50_WIDTH_MULT = 0.6;
+
+ function stackBlockWidthTilesForPlay(baseTiles, heavy) {
+ const b = Number(baseTiles);
+ const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2;
+ const towerHeavyNarrow =
+ !!heavy &&
+ isStackTowerMissionUiMapPlay() &&
+ stackTowerMissionPhase === 'live' &&
+ stackMini &&
+ Number.isFinite(Number(stackMini.progressPct)) &&
+ Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH;
+ if (towerHeavyNarrow) {
+ const w = base * STACK_TOWER_POST50_WIDTH_MULT;
+ return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200));
+ }
+ if (!heavy) return base;
+ const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT;
+ return Math.min(3.2, Math.round(w * 200) / 200);
+ }
+ /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */
+ const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3;
+ const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400;
+ const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12;
+ /** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */
+ const SPACE_SHOOTER_ASTEROID_MAX_HP = 5;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11;
+
+ function spaceShooterAsteroidRadiusFromHpPlay(hp) {
+ const cap = SPACE_SHOOTER_ASTEROID_MAX_HP;
+ const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0));
+ const frac = h <= 0 ? 0 : h / cap;
+ return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP
+ + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac;
+ }
+
+ function spaceShooterShipFootprintCellsPlay(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ return {
+ cw: Math.max(1, Math.min(4, cw)),
+ ch: Math.max(1, Math.min(4, ch)),
+ };
+ }
+
+ /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */
+ function spaceShooterShipBodyScreenPxPlay(zDrawVal) {
+ const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1);
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const kw = 14 / 32;
+ const kh = 22 / 32;
+ return {
+ bodyW: cw * ts * zD * kw,
+ bodyH: ch * ts * zD * kh,
+ };
+ }
+
+ /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */
+ function spaceShooterShipHitRadiusWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const geom = Math.sqrt(Math.max(1, cw * ch));
+ const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts));
+ return Math.max(9, Math.min(cap, base * geom));
+ }
+
+ /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */
+ function spaceShooterShipEdgePadWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return 18;
+ const ts = tileSize || 32;
+ const { cw } = spaceShooterShipFootprintCellsPlay(mapData);
+ return Math.max(18, cw * ts * 0.48);
+ }
+
+ function spaceShooterResetMissionShipStateForAllPlay() {
+ function resetEnt(ent) {
+ if (!ent) return;
+ ent.spaceShooterHits = 0;
+ ent.spaceShooterEliminated = false;
+ ent.spaceShooterInvulnUntil = 0;
+ }
+ resetEnt(me);
+ others.forEach(function (o) {
+ resetEnt(o);
+ });
+ }
+
+ function spaceShooterMissionResolveShipAsteroidHitsPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return;
+ const now = performance.now();
+ const shipR = spaceShooterShipHitRadiusWorldPxPlay();
+
+ function tryHit(ent, cx, cy) {
+ if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return;
+ if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return;
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ if (!a) continue;
+ const dx = cx - a.x;
+ const dy = cy - a.y;
+ const rr = (a.r + shipR) * (a.r + shipR);
+ if (dx * dx + dy * dy > rr) continue;
+ ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1;
+ ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS;
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(ai, 1);
+ spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 });
+ if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) {
+ ent.spaceShooterEliminated = true;
+ spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 });
+ }
+ break;
+ }
+ }
+
+ if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy);
+ others.forEach(function (o) {
+ if (!o) return;
+ tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
+ });
+ if (spaceShooterMissionEveryParticipantEliminatedPlay()) {
+ endSpaceShooterMissionRound('all_dead');
+ }
+ }
+
+ /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */
+ function spaceShooterMissionEveryParticipantEliminatedPlay() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false;
+ if (myId == null) return false;
+ if (!me.spaceShooterEliminated) return false;
+ let anyAlive = false;
+ others.forEach(function (o) {
+ if (!o) return;
+ if (!o.spaceShooterEliminated) anyAlive = true;
+ });
+ return !anyAlive;
+ }
+
+ /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
+ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest';
+ let firstCharacterDefaultResolved = null;
+ /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */
+ let playCharacterIdRoster = [];
+ const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (c && c.id) playCharacterIdRoster.push(String(c.id));
+ });
+ }
+ if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
+ firstCharacterDefaultResolved = String(list[0].id);
+ } else {
+ firstCharacterDefaultResolved = '';
+ }
+ })
+ .catch(() => {
+ firstCharacterDefaultResolved = '';
+ playCharacterIdRoster = [];
+ })
+ .finally(() => {
+ try {
+ if (playBotsEnabled() && mapData) rebalancePreviewBots();
+ } catch (e) { /* map ยังไม่พร้อม */ }
+ });
+ const lobbyLevelParam = params.get('lobbyLevel');
+ const lobbyCaseParam = params.get('case');
+ if (lobbyLevelParam || lobbyCaseParam) {
+ try {
+ window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam };
+ } catch (e) { /* ignore */ }
+ }
+
+ const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h';
+
+ function isDetectiveMinigamePlay() {
+ if (params.get('detectiveReturn') === '1') return true;
+ try {
+ return sessionStorage.getItem('detectiveMinigameReturn') === '1';
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function buildRoomLobbyReturnHref() {
+ let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID);
+ const drParam = (params.get('displayRoom') || '').trim();
+ if (drParam) {
+ h += '&displayRoom=' + encodeURIComponent(drParam);
+ } else {
+ try {
+ const dr = localStorage.getItem('lastCreatedSpaceName');
+ if (dr) h += '&displayRoom=' + encodeURIComponent(dr);
+ } catch (e) { /* ignore */ }
+ }
+ try {
+ const meta = window.__detectiveLobbyMeta;
+ if (meta && meta.level != null && String(meta.level).trim()) {
+ h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim());
+ }
+ if (meta && meta.caseId != null && String(meta.caseId).trim()) {
+ h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim());
+ }
+ } catch (e2) { /* ignore */ }
+ try {
+ if (sessionStorage.getItem('detectiveMinigameReturn') === '1') {
+ h += '&detectiveReturn=1';
+ sessionStorage.removeItem('detectiveMinigameReturn');
+ }
+ } catch (e3) { /* ignore */ }
+ if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) {
+ h += '&detectiveReturn=1';
+ }
+ return h;
+ }
+
+ function finishDetectiveMinigameAndReturnLobby() {
+ quizCarrySessionEnded = true;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
+ const go = function () {
+ window.location.href = buildRoomLobbyReturnHref();
+ };
+ if (socket && socket.connected) {
+ socket.emit('detective-minigame-finished', {}, function (res) {
+ try {
+ if (res && Array.isArray(res.myPlayerEvidence)) {
+ var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; });
+ if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence));
+ else sessionStorage.removeItem('justiceDetectiveMyEvidence');
+ }
+ } catch (e) { /* ignore */ }
+ /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */
+ if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) {
+ showInSceneEvidenceReveal(res, go);
+ } else {
+ go();
+ }
+ });
+ } else {
+ go();
+ }
+ }
+
+ /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */
+ var __evidenceConfigCache = null;
+ function ensureEvidenceConfigLoaded(cb) {
+ if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; }
+ try {
+ fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); })
+ .catch(function () { cb({ cases: {} }); });
+ } catch (e) { cb({ cases: {} }); }
+ }
+
+ var EVIDENCE_RARITY_GLOW = {
+ common: 'rgba(168,180,200,.55)',
+ rare: 'rgba(94,170,255,.7)',
+ legendary: 'rgba(255,186,72,.85)',
+ };
+ var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' };
+
+ /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */
+ function openEvidenceCardLightboxPlay(imageUrl, label, rarity) {
+ if (!imageUrl) return;
+ var rar = String(rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var ov = document.getElementById('evidence-card-lightbox');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'evidence-card-lightbox';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)';
+ ov.innerHTML =
+ '× ' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+ document.body.appendChild(ov);
+ var closeFn = function () { ov.style.display = 'none'; };
+ ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); });
+ }
+ var img = ov.querySelector('.ecl-img');
+ var cap = ov.querySelector('.ecl-cap');
+ if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; }
+ if (cap) cap.textContent = label || '';
+ ov.style.display = 'flex';
+ }
+
+ function showInSceneEvidenceReveal(res, onDone) {
+ var done = false;
+ var finish = function () { if (done) return; done = true; onDone(); };
+ var si = res.suspectIndex;
+ /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */
+ var awarded = (res.awardedCards && Array.isArray(res.awardedCards))
+ ? res.awardedCards.filter(function (c) { return c && c.imageUrl; })
+ : [];
+ if (!awarded.length) {
+ var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || [];
+ var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ var newN = Math.min(owned.length, 1 + freeN);
+ awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; });
+ }
+ if (!awarded.length) { finish(); return; }
+ var newN = awarded.length;
+ var ov = document.createElement('div');
+ ov.id = 'inscene-evidence-reveal';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;backdrop-filter:blur(3px)';
+ var title = document.createElement('div');
+ title.style.cssText = 'font:900 30px/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center';
+ title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!';
+ if (res.grade) {
+ var gradeEl = document.createElement('div');
+ gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9';
+ gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade;
+ title.appendChild(document.createElement('br'));
+ title.appendChild(gradeEl);
+ }
+ var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ if (freeBonus > 0) {
+ var bonusEl = document.createElement('div');
+ bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px';
+ bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ';
+ title.appendChild(document.createElement('br'));
+ title.appendChild(bonusEl);
+ }
+ var row = document.createElement('div');
+ row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw';
+ awarded.forEach(function (c, i) {
+ var rar = String(c.rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var cardWrap = document.createElement('div');
+ cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)';
+ var img = document.createElement('img');
+ img.src = c.imageUrl;
+ img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน';
+ img.style.cssText = 'display:block;width:min(34vw,260px);height:auto;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in';
+ img.title = 'แตะเพื่อดูใหญ่';
+ img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); });
+ img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); };
+ var tag = document.createElement('div');
+ tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92';
+ tag.textContent = EVIDENCE_RARITY_LABEL[rar] || '';
+ cardWrap.appendChild(img);
+ cardWrap.appendChild(tag);
+ row.appendChild(cardWrap);
+ setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160);
+ });
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม';
+ btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:14px 40px;font:800 18px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45)';
+ btn.addEventListener('click', function () {
+ ov.style.transition = 'opacity .25s'; ov.style.opacity = '0';
+ setTimeout(finish, 220);
+ });
+ ov.appendChild(title);
+ ov.appendChild(row);
+ ov.appendChild(btn);
+ document.body.appendChild(ov);
+ /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */
+ setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000);
+ }
+
+ /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */
+ function tryFinishDetectiveMinigameAndReturnLobby() {
+ if (!isDetectiveMinigamePlay()) return false;
+ finishDetectiveMinigameAndReturnLobby();
+ return true;
+ }
+
+ /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
+ const previewFillBots = previewMode && editorEmbedReturn;
+ let detectiveCaseFillBots = false;
+ let detectiveBotSlotCount = 0;
+ function playBotsEnabled() {
+ return previewFillBots || detectiveCaseFillBots;
+ }
+ function playBotTargetHeadcount() {
+ if (previewFillBots) return previewTargetHeadcount;
+ if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount;
+ return countPlayHumans();
+ }
+ /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */
+ const PLAY_EMBED_USER_ZOOM_MIN = 0.5;
+ const PLAY_EMBED_USER_ZOOM_MAX = 3;
+ const PLAY_EMBED_ZOOM_STEP_KEY = 1.12;
+ let playEmbedUserZoomMul = 1;
+ let lastPlayZDrawForInput = 1.4;
+ /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */
+ function playDomSyncZoom() {
+ const z = lastPlayZDrawForInput;
+ return Number.isFinite(z) && z > 0 ? z : zoom;
+ }
+ const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
+ const PREVIEW_BOT_PREFIX = '__pv_bot_';
+ let previewBotSeq = 0;
+ /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
+ let lastStackPreviewActorId = null;
+ let lastStackPreviewActorUntil = 0;
+ /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
+ const STACK_PREVIEW_TURN_COUNT = 6;
+ let stackPreviewTurnOrder = null;
+ let stackPreviewTurnIndex = 0;
+ let stackPreviewBotThinkUntil = 0;
+
+ function rebuildStackPreviewTurnOrder() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
+ stackPreviewTurnOrder = null;
+ return;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ stackPreviewTurnIndex = 0;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function advanceStackPreviewTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
+ stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function isStackPreviewHumanTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return !!(cur && cur.kind === 'human');
+ }
+
+ function getStackPreviewCurrentSeat() {
+ if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
+ }
+
+ function getStackDropActorSeat() {
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ return getStackPreviewCurrentSeat();
+ }
+ return 1;
+ }
+
+ function markStackNextDropVisualDirty() {
+ if (stackMini) stackMini.nextDropVisualDirty = true;
+ }
+
+ function pickStackBlockHeavyVisual(seat) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
+ /** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */
+ if (isStackTowerMissionUiMapPlay()) {
+ if (!(stackTowerMissionPhase === 'live' && stackMini)) return false;
+ const p = Number(stackMini.progressPct);
+ if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false;
+ return !!hU;
+ }
+ const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0)));
+ if (pct <= 0) return false;
+ if (!hU) return false;
+ if (Math.random() * 100 >= pct) return false;
+ return true;
+ }
+
+ function ensureStackNextDropVisual() {
+ if (!stackMini || !stackMini.nextDropVisualDirty) return;
+ stackMini.pendingDropSeat = getStackDropActorSeat();
+ stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat);
+ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy);
+ stackMini.nextDropVisualDirty = false;
+ }
+
+ function normalizePlayStackSixUrlsFromTiming(arr) {
+ const out = ['', '', '', '', '', ''];
+ if (!Array.isArray(arr)) return out;
+ for (let i = 0; i < 6; i++) {
+ out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
+ if (out[i]) ensureGauntletAssetImage(out[i]);
+ }
+ return out;
+ }
+
+ function resolveStackBlockSpriteRec(seat, heavy) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const tryHeavy = !!heavy;
+ const order = tryHeavy
+ ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]]
+ : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]];
+ for (let ti = 0; ti < order.length; ti++) {
+ const u = normalizeGauntletAssetUrlForPlay(order[ti] || '');
+ if (!u) continue;
+ return ensureGauntletAssetImage(u);
+ }
+ return null;
+ }
+
+ /**
+ * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback
+ * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain
+ * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult
+ */
+ function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) {
+ const o = opts || {};
+ const rec = resolveStackBlockSpriteRec(seat, heavy);
+ const img = rec && rec.img;
+ if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ if (drawW > 0 && drawH > 0) {
+ const scale = Math.max(drawW / iw, drawH / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = sx + (drawW - dw) * 0.5;
+ const dy = sy + (drawH - dh) * 0.5;
+ const prevSmooth = ctx.imageSmoothingEnabled;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(sx, sy, drawW, drawH);
+ ctx.clip();
+ try {
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ } finally {
+ ctx.imageSmoothingEnabled = prevSmooth;
+ }
+ ctx.restore();
+ if (o.missOverlay) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.42)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ }
+ return true;
+ }
+ }
+ const hue = ((hueFallback % 360) + 360) % 360;
+ ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)');
+ ctx.fillRect(sx, sy, drawW, drawH);
+ return false;
+ }
+
+ if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; }
+
+ const socket = io({ path: BASE + '/socket.io' });
+ window.__jdGameSocket = socket;
+ const canvas = document.getElementById('game-canvas');
+ const ctx = canvas.getContext('2d');
+ let mapData = null, tileSize = 32, myId = null;
+ let stackMini = null;
+ let stackFall = null;
+ /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */
+ let stackMissDebris = [];
+ let _stackDebrisLastT = 0;
+ /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */
+ let stackTowerSlingImg = null;
+ /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */
+ let stackTowerScorePlus10Img = null;
+ let stackTowerScorePlus20Img = null;
+ let stackTowerScoreLightImg = null;
+ let stackTowerScoreX2Img = null;
+ /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */
+ let stackTowerHeartMinusImg = null;
+ /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */
+ let stackTowerLifeBarImg = null;
+ let stackTowerLifeHitImg = null;
+ let stackTowerLifeFullImg = null;
+ /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */
+ let violentCrimeLifeBarImg = null;
+ let violentCrimeLifeHitImg = null;
+ let violentCrimeLifeFullImg = null;
+ /** @type {{ until: number } | null} */
+ let stackTowerHeartMinusFx = null;
+ const STACK_TOWER_HEART_MINUS_FX_MS = 1500;
+ const STACK_TOWER_PERFECT_GLOW_MS = 1500;
+ /** ป๊อปอัป +10 / +20 เหนือบล็อก */
+ const STACK_TOWER_SCORE_POPUP_MS = 2000;
+ /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */
+ const STACK_TOWER_PERFECT_X2_MS = 2200;
+ /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */
+ const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998;
+ let lastStackTickMs = performance.now();
+ let lastStackTowerScrollBgTickMs = performance.now();
+ let mapBackgroundImg = null;
+ /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */
+ const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp';
+ const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png';
+ const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png';
+ let playScrollBgIntroImg = null;
+ let playScrollBgLoopImg = null;
+ /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */
+ let playScrollBgPx = 0;
+ let playScrollBgSpeedPxPerSec = 56;
+ let playScrollBgOff = false;
+ /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */
+ let playScrollBgFlowDown = false;
+
+ function playScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID);
+ }
+
+ function playScrollBgDrawActive() {
+ if (!playScrollBgMapEligible() || playScrollBgOff) return false;
+ const a = playScrollBgIntroImg;
+ const b = playScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ function reloadPlayScrollBgFromMap() {
+ playScrollBgIntroImg = null;
+ playScrollBgLoopImg = null;
+ playScrollBgPx = 0;
+ if (!playScrollBgMapEligible()) return;
+ const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {};
+ playScrollBgOff = raw.enabled === false;
+ playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56));
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
+ playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ if (playScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ playScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { playScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { playScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function playScrollBgSyncInitialScrollToBottom() {
+ const intro = playScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */
+ if (playScrollBgFlowDown) {
+ playScrollBgPx = 0;
+ } else {
+ playScrollBgPx = Math.max(0, drawHIntro - chR);
+ }
+ }
+
+ function drawPlayScrollBgFullCanvas(cw, ch) {
+ const intro = playScrollBgIntroImg;
+ const loop = playScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const S = playScrollBgPx;
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 2;
+ const flowDown = playScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ function tickPlayScrollBg(dtSec) {
+ if (!playScrollBgDrawActive()) return;
+ playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec;
+ }
+
+ /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */
+ const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07';
+ /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */
+ const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000;
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
+ let gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ let gauntletCrownRunwayBgOff = false;
+ let gauntletCrownRunwayBgSpeedPxPerSec = 48;
+ let gauntletCrownRunwayBgScrollPx = 0;
+ let gauntletCrownRunwayBgFinishLatched = false;
+ /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */
+ let gauntletFinishPhaseSignaled = false;
+ /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */
+ let gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */
+ let gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */
+ let gauntletCrownRunwayClientMissionShown = false;
+ /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */
+ const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8;
+ const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png';
+ let gauntletCrownScorePenaltyImg = null;
+ function ensureGauntletCrownScorePenaltyImgPlay() {
+ if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg;
+ const im = new Image();
+ im.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ gauntletCrownScorePenaltyImg = im;
+ return im;
+ }
+ let lastGauntletCrownRunwayBgTickMs = 0;
+ /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */
+ let gauntletCrownRunwaySpawnScrollSnapped = false;
+
+ function gauntletCrownRunwayBgMapEligiblePlay() {
+ return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID);
+ }
+
+ function gauntletCrownRunwayBgNatDims(im) {
+ if (!im) return null;
+ if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height };
+ if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight };
+ return null;
+ }
+
+ function gauntletCrownRunwayBgImageSlotReadyPlay(im) {
+ return !!gauntletCrownRunwayBgNatDims(im);
+ }
+
+ function gauntletCrownRunwayBgStripWidthsAtCh(chR) {
+ const ch0 = Math.max(1, Math.round(chR));
+ const wOf = (im) => {
+ const d = gauntletCrownRunwayBgNatDims(im);
+ if (!d) return 0;
+ return Math.max(1, Math.round(d.nw * (ch0 / d.nh)));
+ };
+ return {
+ w0: wOf(gauntletCrownRunwayBgImgs[0]),
+ w1: wOf(gauntletCrownRunwayBgImgs[1]),
+ w2: wOf(gauntletCrownRunwayBgImgs[2]),
+ w3: wOf(gauntletCrownRunwayBgImgs[3]),
+ w4: wOf(gauntletCrownRunwayBgImgs[4]),
+ };
+ }
+
+ function gauntletCrownRunwayBgImagesReadyPlay() {
+ for (let i = 0; i < 5; i++) {
+ if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false;
+ }
+ return true;
+ }
+
+ /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */
+ function ensureGauntletCrownRunwayBgPlaceholdersPlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return;
+ const W = 960;
+ const H = 540;
+ const hues = [352, 210, 165, 225, 38];
+ for (let i = 0; i < 5; i++) {
+ if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue;
+ const c = document.createElement('canvas');
+ c.width = W;
+ c.height = H;
+ const g = c.getContext('2d');
+ const h0 = hues[i];
+ const grd = g.createLinearGradient(0, 0, W, H * 0.9);
+ grd.addColorStop(0, `hsl(${h0},26%,20%)`);
+ grd.addColorStop(0.45, `hsl(${h0},18%,14%)`);
+ grd.addColorStop(1, `hsl(${h0},12%,10%)`);
+ g.fillStyle = grd;
+ g.fillRect(0, 0, W, H);
+ g.strokeStyle = 'rgba(255,255,255,0.06)';
+ g.lineWidth = 1;
+ for (let x = 0; x < W; x += 48) {
+ g.beginPath();
+ g.moveTo(x, 0);
+ g.lineTo(x + H * 0.04, H);
+ g.stroke();
+ }
+ const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85);
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
+ vg.addColorStop(1, 'rgba(0,0,0,0.38)');
+ g.fillStyle = vg;
+ g.fillRect(0, 0, W, H);
+ g.fillStyle = 'rgba(255,255,255,0.1)';
+ g.font = '10px system-ui,sans-serif';
+ g.textAlign = 'right';
+ g.fillText(String(i + 1), W - 10, H - 8);
+ gauntletCrownRunwayBgImgs[i] = c;
+ }
+ }
+
+ function gauntletCrownRunwayBgDrawActivePlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false;
+ return gauntletCrownRunwayBgImagesReadyPlay();
+ }
+
+ function gauntletCrownRunwayFinishLineInArtFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let lineInArt = Number(raw.finishLineInFinishArtFrac);
+ if (!Number.isFinite(lineInArt)) {
+ lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12;
+ }
+ return Math.max(0, Math.min(0.92, lineInArt));
+ }
+
+ function gauntletCrownRunwayFinishLineStripXPlay(g) {
+ if (!g) return null;
+ return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay();
+ }
+
+ /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */
+ function gauntletCrownRunwayFinishLineMapWorldXPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const stripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (stripX == null) return null;
+ return stripX - gauntletCrownRunwayBgScrollPx;
+ }
+
+ function gauntletCrownRunwayStripFrozenPlay() {
+ return gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched;
+ }
+
+ /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */
+ function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() {
+ if (!mapData || !isGauntletCrownHeistMapPlay()) return null;
+ const ts = mapData.tileSize || 32;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ let leadTx = -Infinity;
+ const consider = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x);
+ if (!Number.isFinite(tx)) return;
+ leadTx = Math.max(leadTx, tx);
+ };
+ consider(me);
+ others.forEach((o, id) => {
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ consider(o);
+ });
+ if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null;
+ let alignWorld = (leadTx + cw * 0.5) * ts;
+ if (mx != null && Number.isFinite(mx)) {
+ alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts);
+ }
+ return alignWorld;
+ }
+
+
+ /**
+ * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px)
+ * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ
+ */
+ function gauntletCrownRunwayFinishAlignMapWorldXPlay() {
+ if (!mapData) return null;
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) {
+ return gauntletCrownRunwayFinishAlignLatchedWorldX;
+ }
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const ts = mapData.tileSize || 32;
+ const tileX = Number(raw.finishAlignMapTileX);
+ if (Number.isFinite(tileX)) return (tileX + 0.5) * ts;
+ const tileFrac = Number(raw.finishAlignMapTileFrac);
+ if (Number.isFinite(tileFrac)) {
+ const w = mapData.width || 20;
+ return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts;
+ }
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ return gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ }
+ return null;
+ }
+
+ /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */
+ function gauntletCrownRunwayFinishLineScreenFracPlay(g) {
+ if (!g) return null;
+ const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineWorldX == null) return null;
+ return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld);
+ }
+
+ function gauntletCrownRunwayFinishStopViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75;
+ let vp = Number(raw.finishStopViewportFrac);
+ if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp));
+ let legacy = Number(raw.finishStopScreenFrac);
+ if (Number.isFinite(legacy)) {
+ legacy = Math.max(0.08, Math.min(0.96, legacy));
+ return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42));
+ }
+ /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */
+ return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34;
+ }
+
+ function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let v = Number(raw.obstacleFadeStartViewportFrac);
+ /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */
+ if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9;
+ return Math.max(0.45, Math.min(1.15, v));
+ }
+
+ /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */
+ function gauntletCrownRunwayObstacleDrawAlphaPlay() {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return 1;
+ if (gauntletCrownRunwayBgFinishLatched) return 0;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return 1;
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return 1;
+ const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay();
+ if (screenFrac >= fadeStart) return 1;
+ if (screenFrac <= stopAt) return 0;
+ return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt);
+ }
+
+ function gauntletCrownRunwayBgHideObstaclesPlay() {
+ return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001;
+ }
+
+ /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */
+ function gauntletCrownRunwayAvatarRunAllowedPlay() {
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) {
+ if (gauntletCrownRunwayBgFinishLatched) return false;
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false;
+ return true;
+ }
+ return true;
+ }
+
+ function drawImageCoverCanvasPlay(ctx, img, cwR, chR) {
+ const d = gauntletCrownRunwayBgNatDims(img);
+ if (!d) return;
+ const iw = d.nw;
+ const ih = d.nh;
+ if (!iw || !ih) return;
+ const scale = Math.max(cwR / iw, chR / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = (cwR - dw) / 2;
+ const dy = (chR - dh) / 2;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ }
+
+ function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
+ const d0 = gauntletCrownRunwayBgNatDims(img);
+ if (!d0 || segW <= 0) return;
+ const natW = d0.nw;
+ const natH = d0.nh;
+ const v0 = scrollLeft;
+ const v1 = scrollLeft + cwR;
+ const o0 = Math.max(worldX0, v0);
+ const o1 = Math.min(worldX0 + segW, v1);
+ if (o1 <= o0) return;
+ const u0 = ((o0 - worldX0) / segW) * natW;
+ const u1 = ((o1 - worldX0) / segW) * natW;
+ const dx = o0 - scrollLeft;
+ const dw = o1 - o0;
+ /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */
+ ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR);
+ }
+
+ /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */
+ function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) {
+ const start = gauntletCrownRunwayBgImgs[0];
+ const L2 = gauntletCrownRunwayBgImgs[1];
+ const L3 = gauntletCrownRunwayBgImgs[2];
+ const L4 = gauntletCrownRunwayBgImgs[3];
+ const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ const cycle = w1 + w2 + w3;
+ const S = scrollView;
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR);
+ if (cycle <= 0) return;
+ const ws = [w1, w2, w3];
+ const ims = [L2, L3, L4];
+ const uTotal = Math.max(0, S - w0);
+ let cycN = Math.floor(uTotal / cycle);
+ let uRem = uTotal - cycN * cycle;
+ let si = 0;
+ while (si < 3 && uRem >= ws[si]) {
+ uRem -= ws[si];
+ si++;
+ }
+ if (si >= 3) {
+ cycN++;
+ si = 0;
+ uRem = 0;
+ }
+ let acc = 0;
+ for (let k = 0; k < si; k++) acc += ws[k];
+ let curLeft = w0 + cycN * cycle + acc;
+ let guard = 0;
+ let idx = si;
+ while (curLeft < S + cwR && guard++ < 500) {
+ drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
+ curLeft += ws[idx];
+ idx = (idx + 1) % 3;
+ }
+ }
+
+ /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */
+ function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) {
+ const S = scrollView;
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ const segs = [
+ { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 },
+ { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 },
+ { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 },
+ { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 },
+ { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 },
+ ];
+ for (let i = 0; i < segs.length; i++) {
+ const g = segs[i];
+ if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR);
+ }
+ }
+
+ /**
+ * @param {number} canvasW
+ * @param {number} canvasH
+ * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง)
+ */
+ function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ const finish = gauntletCrownRunwayBgImgs[4];
+ const align = worldAlign && typeof worldAlign === 'object'
+ && Number.isFinite(worldAlign.worldMinX)
+ && Number.isFinite(worldAlign.worldMaxX)
+ && Number.isFinite(worldAlign.camX)
+ && Number.isFinite(worldAlign.camY)
+ && Number.isFinite(worldAlign.zDraw)
+ && Number.isFinite(worldAlign.mapWpx)
+ && Number.isFinite(worldAlign.mapHpx);
+ /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */
+ /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */
+ const cwR = align
+ ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX)
+ : Math.max(1, Math.round(canvasW));
+ const chR = align
+ ? Math.max(1, Math.round(worldAlign.mapHpx))
+ : Math.max(1, Math.round(canvasH));
+ const scrollView = align
+ ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX)
+ : gauntletCrownRunwayBgScrollPx;
+ if (align) {
+ const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvasW, canvasH);
+ ctx.save();
+ ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
+ ctx.beginPath();
+ ctx.rect(0, 0, mapWpx, mapHpx);
+ ctx.clip();
+ ctx.translate(worldMinX, 0);
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ return;
+ }
+ ctx.save();
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ }
+
+ /**
+ * Shared runway strip math (viewport in strip space). null if not drawable.
+ * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34)
+ * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12)
+ * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9)
+ */
+ function gauntletCrownRunwayScrollGeometryPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const halfW = canvas.width / (2 * zGaRaw);
+ const worldMinX = gCam.px - halfW;
+ const cwWorld = canvas.width / zGaRaw;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ const w4Safe = Math.max(w4, 1);
+ const totalStrip = w0 + w1 + w2 + w3 + w4Safe;
+ if (!(totalStrip > 8)) return null;
+ const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX;
+ const right = scrollView + cwWorld;
+ const finishStart = w0 + w1 + w2 + w3;
+ const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe);
+ const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0;
+ const minScrollForFinishPass = geoMinScroll + wideVpPad;
+ return {
+ scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass,
+ };
+ }
+
+ /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */
+ function gauntletCrownRunwayScrollStripStopZoneReachedPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return false;
+ if (g.scrollView < g.minScrollForFinishPass - 0.01) return false;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay();
+ if (finishMapX == null) return false;
+ return finishMapX <= alignWorldX + 1.5;
+ }
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return false;
+ return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008;
+ }
+
+ /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */
+ function gauntletCrownRunwayFinishStopScrollPxPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineStripX == null) return null;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ return finishLineStripX - alignWorldX;
+ }
+ const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const targetScrollView = finishLineStripX - stopVp * g.cwWorld;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const worldMinX = gCam.px - canvas.width / (2 * zGaRaw);
+ return targetScrollView - worldMinX;
+ }
+
+ /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */
+ function gauntletCrownRunwaySnapScrollToFinishStopPlay() {
+ const t = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (t == null) return;
+ gauntletCrownRunwayBgScrollPx = t;
+ }
+
+ /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → snap เป๊ะ → เริ่ม freeze → (latch+mission ทีหลังใน tick) */
+ function gauntletCrownRunwayBeginFinishStopPlay() {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ /* แจ้ง server ครั้งเดียว: ถึงเส้นชัยแล้ว → หยุด obstacle + collision (กัน -10 ตอน obstacle หายแล้ว) */
+ if (!gauntletFinishPhaseSignaled) {
+ gauntletFinishPhaseSignaled = true;
+ try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ }
+ }
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { draw(); } catch (_d) { /* ignore */ }
+ }
+
+ /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */
+ function gauntletCrownRunwayMaintainFinishScrollPlay() {
+ if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return;
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return;
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ }
+
+ function resetGauntletCrownRunwaySpawnScrollSnap() {
+ gauntletCrownRunwaySpawnScrollSnapped = false;
+ }
+
+ /**
+ * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0)
+ * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32)
+ * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง
+ * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand.
+ */
+ function trySnapGauntletCrownRunwayScrollToSpawnsPlay() {
+ if (gauntletCrownRunwaySpawnScrollSnapped) return;
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return;
+ if (!gauntletCrownRunwayBgImagesReadyPlay()) return;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ if (!(w0 > 0)) return;
+ const slots = collectGauntletSpawnSlotsPlay(mapData);
+ let minTX = Infinity;
+ if (slots.length) minTX = Math.min(...slots.map((s) => s.x));
+ if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x));
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x));
+ });
+ if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1;
+ const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32));
+ gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX;
+ gauntletCrownRunwaySpawnScrollSnapped = true;
+ }
+
+ function reloadGauntletCrownRunwayBgFromMap() {
+ gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ gauntletCrownRunwayBgScrollPx = 0;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ lastGauntletCrownRunwayBgTickMs = 0;
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ gauntletCrownRunwayBgOff = raw.enabled === false;
+ gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
+ if (gauntletCrownRunwayBgOff) return;
+ const srcs = [
+ (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START,
+ (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2,
+ (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3,
+ (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4,
+ (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH,
+ ];
+ let pending = 5;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ ensureGauntletCrownRunwayBgPlaceholdersPlay();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ for (let i = 0; i < 5; i++) {
+ const im = new Image();
+ const idx = i;
+ im.onload = () => {
+ gauntletCrownRunwayBgImgs[idx] = im;
+ bump();
+ };
+ im.onerror = () => { bump(); };
+ im.src = srcs[i];
+ }
+ }
+
+ function tickGauntletCrownRunwayBgPlay(dtSec) {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */
+ if (specialQuizFreeze) return;
+ /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */
+ if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return;
+ if (gauntletCrownRunwayBgFinishLatched) return;
+
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) {
+ gauntletCrownRunwayBgFinishLatched = true;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) {
+ gauntletCrownRunwayClientMissionShown = true;
+ window.setTimeout(function () {
+ try {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownHowtoVisible = false;
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ showGauntletCrownMissionOverlay(gauntletCrownHeistBuildLocalCrownMissionPlay());
+ } catch (_e) { /* ignore */ }
+ try {
+ draw();
+ } catch (_e2) { /* ignore */ }
+ }, 340);
+ }
+ }
+ return;
+ }
+
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ try {
+ syncGauntletCrownJumpButton();
+ } catch (_sj) { /* ignore */ }
+ try {
+ draw();
+ } catch (_d) { /* ignore */ }
+ return;
+ }
+ const gcrInc = (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec;
+ /* ถ้าก้าวถัดไป "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น (ไม่วิ่งเลย) แล้วเริ่มจบเกมทันที */
+ const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null
+ && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01
+ && gauntletCrownRunwayBgScrollPx + gcrInc >= gcrLiveStopPx) {
+ gauntletCrownRunwayBgScrollPx = gcrLiveStopPx;
+ gauntletCrownRunwayBeginFinishStopPlay();
+ return;
+ }
+ gauntletCrownRunwayBgScrollPx += gcrInc;
+ }
+
+ /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */
+ const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
+ const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
+ let stackTowerScrollBgIntroImg = null;
+ let stackTowerScrollBgLoopImg = null;
+ let stackTowerScrollBgPx = 0;
+ let stackTowerScrollBgSpeedPxPerSec = 40;
+ let stackTowerScrollBgOff = false;
+ let stackTowerScrollBgFlowDown = false;
+ /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */
+ let stackTowerScrollBgSrawWorldBaselinePlay = null;
+ /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */
+ let stackTowerScrollBgLoopDrawHPlay = 0;
+ /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */
+ let stackTowerBgScrollStepAnimPlay = null;
+ let stackTowerBgScrollPendingStepDeltaPlay = 0;
+
+ function stackTowerScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID);
+ }
+
+ function stackTowerScrollBgImagesReady() {
+ if (!stackTowerScrollBgMapEligible()) return false;
+ const a = stackTowerScrollBgIntroImg;
+ const b = stackTowerScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */
+ function stackTowerScrollBgDrawActive() {
+ return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff;
+ }
+
+ function reloadStackTowerScrollBgFromMap() {
+ stackTowerScrollBgIntroImg = null;
+ stackTowerScrollBgLoopImg = null;
+ stackTowerScrollBgPx = 0;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgLoopDrawHPlay = 0;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ if (!stackTowerScrollBgMapEligible()) return;
+ const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {};
+ stackTowerScrollBgOff = raw.enabled !== true;
+ {
+ const spNum = Number(raw.speedPxPerSec);
+ stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ }
+ /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ stackTowerScrollBgFlowDown = !rawIsDownKeyword;
+ if (stackTowerScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ const intro0 = stackTowerScrollBgIntroImg;
+ const loop0 = stackTowerScrollBgLoopImg;
+ if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) {
+ const cwR0 = Math.max(1, Math.round(canvas.width));
+ stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth)));
+ }
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function stackTowerScrollBgSyncInitialScrollToBottom() {
+ const intro = stackTowerScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ if (stackTowerScrollBgFlowDown) {
+ stackTowerScrollBgPx = 0;
+ } else {
+ /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */
+ stackTowerScrollBgPx = drawHIntro - chR;
+ }
+ }
+
+ /**
+ * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop
+ * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ
+ */
+ function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) {
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(S) || 0;
+ if (s < dI) return s;
+ return s - Math.floor((s - dI) / dH) * dH;
+ }
+
+ /**
+ * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0)
+ * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด)
+ */
+ function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) {
+ if (Sraw0 == null) return 0;
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(Sraw) || 0;
+ const s0 = Number(Sraw0) || 0;
+ const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH);
+ const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH);
+ return fs - fs0;
+ }
+
+ /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */
+ function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) {
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0;
+ const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0;
+ const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
+ const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop);
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 96;
+ const flowDown = stackTowerScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */
+ y = drawHIntro + n * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */
+ function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) {
+ if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!loop || !loop.complete || !loop.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4);
+ const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx;
+ const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw
+ + getStackTowerPost50BgScrollExtraPxPlay(chR);
+ const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop;
+ let y = -scroll;
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ while (y < skyScreenH + drawHLoop) {
+ if (y + drawHLoop > 0) {
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop);
+ }
+ y += drawHLoop;
+ }
+ ctx.restore();
+ }
+
+ function tickStackTowerBgScrollStepAnimPlay() {
+ const q = stackTowerBgScrollStepAnimPlay;
+ if (!q) return;
+ const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur));
+ const e = 1 - Math.pow(1 - t, 3);
+ stackTowerScrollBgPx = q.from + (q.to - q.from) * e;
+ if (t >= 1) {
+ stackTowerScrollBgPx = q.to;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ if (stackTowerBgScrollPendingStepDeltaPlay !== 0) {
+ const d = stackTowerBgScrollPendingStepDeltaPlay;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ startStackTowerBgScrollStepAnimPlay(d);
+ }
+ }
+ }
+
+ function getStackTowerBgScrollStepEveryLayersPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const n = Math.floor(Number(raw.stepEveryLayers));
+ return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0;
+ }
+
+ function getStackTowerBgScrollStepAnimMsPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 520;
+ const t = Math.floor(Number(raw.stepAnimMs));
+ return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520;
+ }
+
+ function getStackTowerBgScrollStepScrollPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const d = Math.floor(Number(raw.stepScrollPx));
+ return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0;
+ }
+
+ function startStackTowerBgScrollStepAnimPlay(deltaPx) {
+ if (!stackTowerScrollBgDrawActive() || !deltaPx) return;
+ if (stackTowerBgScrollStepAnimPlay) {
+ stackTowerBgScrollPendingStepDeltaPlay += deltaPx;
+ return;
+ }
+ const dur = getStackTowerBgScrollStepAnimMsPlay();
+ stackTowerBgScrollStepAnimPlay = {
+ from: stackTowerScrollBgPx,
+ to: stackTowerScrollBgPx + deltaPx,
+ t0: performance.now(),
+ dur,
+ };
+ }
+
+ function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) {
+ if (!stackTowerScrollBgDrawActive()) return;
+ const every = getStackTowerBgScrollStepEveryLayersPlay();
+ if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return;
+ let delta = getStackTowerBgScrollStepScrollPxPlay();
+ if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200);
+ delta = Math.min(8000, Math.max(8, delta));
+ startStackTowerBgScrollStepAnimPlay(delta);
+ }
+
+ function getStackTowerStripReleaseGapWorldPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null;
+ const g = Number(raw.releaseGapWorldPx);
+ if (!Number.isFinite(g) || g < 0) return null;
+ return Math.min(800, g);
+ }
+
+ function updateStackTowerSwingYForStripGapPlay() {
+ if (!stackMini || !isStackTowerMissionUiMapPlay()) return;
+ const gap = getStackTowerStripReleaseGapWorldPxPlay();
+ if (gap == null) return;
+ const m = stackMini;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const nLay = m.layers ? m.layers.length : 0;
+ const topBlockTopY = m.floorWorldY - nLay * lh;
+ const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh);
+ m.swingWorldY = topBlockTopY - gap + swingLift;
+ }
+
+ function tickStackTowerScrollBg(dtSec) {
+ if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return;
+ if (!stackTowerScrollBgImagesReady()) return;
+ tickStackTowerBgScrollStepAnimPlay();
+ if (stackTowerBgScrollStepAnimPlay) return;
+ const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec;
+ if (spd <= 0) return;
+ /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */
+ stackTowerScrollBgPx += spd;
+ }
+
+ /**
+ * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50)
+ * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง)
+ */
+ function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const zPn = Number(zPan) > 0 ? zPan : zoom;
+ const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720));
+ const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn;
+ const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx);
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ let totalScreen = boostScreen + post50Screen;
+ if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth
+ && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) {
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth)));
+ const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen;
+ if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw;
+ const S0 = stackTowerScrollBgSrawWorldBaselinePlay;
+ let stripY;
+ if (stackTowerScrollBgFlowDown) {
+ const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop);
+ stripY = (boostScreen + post50Screen) * 2 - Sview;
+ } else {
+ stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0);
+ }
+ totalScreen = stripY;
+ } else if (stackTowerScrollBgDrawActive()) {
+ const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
+ totalScreen = px + boostScreen + post50Screen;
+ }
+ return totalScreen;
+ }
+
+ /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
+ let mapGridImageImgs = [];
+ let mapGridImageHeldImgs = [];
+ let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 };
+ const others = new Map();
+ /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */
+ let mg3TermLog = [];
+ let mg3TermLineNo = 0;
+ const MG3_TERM_MAX = 240;
+ function mg3TermPush(lines) {
+ const arr = Array.isArray(lines) ? lines : [lines];
+ for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i]));
+ while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift();
+ }
+ function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; }
+ function mg3TermBoot() {
+ mg3TermLog = []; mg3TermLineNo = 0;
+ mg3TermPush([
+ '[INIT] Booting decryption sequence...',
+ '[SYS] Locating target server...',
+ '[SYS] Connection established. Status: SECURE.',
+ '[HACK] Bypassing "Vault Lockdown" protocol...',
+ '[HACK] Disabling Cyber Security Check...',
+ '[LOAD] Injecting Decryption_Core.exe...',
+ '[READY] Awaiting manual code block alignment...',
+ '>>> STACKING SEQUENCE START <<<',
+ ]);
+ }
+ function mg3TermOnPlace(perfect, progressPct) {
+ mg3TermLineNo += 1;
+ const n = mg3TermLineNo;
+ if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) {
+ mg3TermPush([
+ '>>> Compile Line [100]: SUCCESS. Maximum stack reached.',
+ '[DATA]: Decryption progress: 100%. Master key generated.',
+ '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.',
+ '[SYS]: Evidence files unlocked. Initiating secure download...',
+ '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<',
+ ]);
+ } else if (perfect) {
+ mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!');
+ } else {
+ mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.');
+ }
+ }
+ function mg3TermOnMiss(attemptsRemaining) {
+ if (attemptsRemaining <= 0) {
+ mg3TermPush([
+ '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)',
+ '[ALERT]: Cyber Security Check triggered! Intruder traced.',
+ '[SYS]: Initiating "Vault Lockdown" protocol...',
+ '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<',
+ ]);
+ } else {
+ mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)');
+ }
+ }
+
+ /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
+ const STACK_PREVIEW_HUD_LOG_MAX = 8;
+ let stackPreviewHudLog = [];
+ function stackPreviewPushHudLog(line) {
+ if (!playBotsEnabled()) return;
+ stackPreviewHudLog.push(String(line || '').slice(0, 96));
+ while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
+ }
+ function stackPreviewLogStackDrop(hit, actor) {
+ if (!playBotsEnabled() || !hit) return;
+ const bid = actor && actor.botId;
+ let nm = 'NODE';
+ if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`);
+ else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`);
+ else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`);
+ }
+ const keys = {};
+ const characterImages = {};
+ /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */
+ const cyberHudScoreAvatarUrlCache = new Map();
+ const CYBER_HUD_AV_URL_CACHE_CAP = 56;
+ function createDefaultAvatarImg() {
+ const c = document.createElement('canvas');
+ c.width = 64; c.height = 64;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = '#7aa2f7';
+ ctx.beginPath();
+ ctx.arc(32, 22, 14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(32, 48, 18, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#1a1b26';
+ ctx.beginPath();
+ ctx.arc(28, 20, 3, 0, Math.PI * 2);
+ ctx.arc(36, 20, 3, 0, Math.PI * 2);
+ ctx.fill();
+ const img = new Image();
+ img.src = c.toDataURL('image/png');
+ return img;
+ }
+ const defaultAvatarImg = createDefaultAvatarImg();
+ const characterAnimations = {};
+ const characterIdleAnimations = {};
+ const CHARACTER_ANIM_FRAMES = 4;
+ const CHARACTER_ANIM_FRAME_MS = 200;
+
+ /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
+ /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */
+ function gauntletRunnerAnimActivePlay() {
+ try {
+ return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ } catch (e) { return false; }
+ }
+
+ function walkAnimPhaseIndex(now, isWalking) {
+ if (!isWalking) return 0;
+ const t = (typeof now === 'number' ? now : Date.now());
+ /* gauntlet วิ่งหลบ: ย่นเวลาเฟรม ~1.85× ให้ขาสลับเร็ว = วิ่ง (ไม่ใช่เดิน) */
+ const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.54 : CHARACTER_ANIM_FRAME_MS;
+ return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES;
+ }
+
+ /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */
+ function pickLoadedWalkFrameIndex(anim, phase) {
+ if (!anim || !anim.frames || !anim.frames.length) return -1;
+ const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1);
+ for (let k = maxK; k >= 0; k--) {
+ const f = anim.frames[k];
+ if (f && f.complete && f.naturalWidth) return k;
+ }
+ return -1;
+ }
+
+ /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */
+ function isUploadedCharAssetId(id) {
+ if (!id) return false;
+ const s = String(id).trim();
+ return /^char-\d/i.test(s);
+ }
+
+ function useMultiFrameIdleSpriteSheets(id) {
+ if (!id) return true;
+ return !isUploadedCharAssetId(id);
+ }
+
+ function ensureCharacterIdleAnim(id, dir) {
+ const d = dir || 'down';
+ /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */
+ if (!useMultiFrameIdleSpriteSheets(id)) {
+ const key = String(id) + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+ const key = id + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+
+ /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */
+ function idleAnimPhaseIndex(now) {
+ const t = typeof now === 'number' ? now : Date.now();
+ return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
+ }
+
+ function characterIdleSpritesVisible(id, dir) {
+ if (!id) return false;
+ const anim = ensureCharacterIdleAnim(id, dir || 'down');
+ if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true;
+ for (let i = 0; i < anim.frames.length; i++) {
+ const f = anim.frames[i];
+ if (f && f.complete && f.naturalWidth) return true;
+ }
+ return false;
+ }
+
+ /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
+ const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
+ const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
+ const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const playLayerMode = {};
+ /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
+ const playLayerAllDirsQueued = {};
+ /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */
+ let playCharLayerApi = { status: 'idle', map: null };
+ /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */
+ const playCharDiskLayersById = Object.create(null);
+
+ function ensurePlayCharLayerListFetch() {
+ if (playCharLayerApi.status !== 'idle') return;
+ playCharLayerApi.status = 'loading';
+ fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ const map = Object.create(null);
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (!c || !c.id) return;
+ playCharacterIdRoster.push(String(c.id));
+ map[c.id] = !!c.hasLayerFiles;
+ if (Array.isArray(c.layers) && c.layers.length) {
+ const st = new Set();
+ c.layers.forEach((n) => {
+ if (typeof n === 'string' && n.length) st.add(n);
+ });
+ if (st.size) playCharDiskLayersById[c.id] = st;
+ else delete playCharDiskLayersById[c.id];
+ } else {
+ delete playCharDiskLayersById[c.id];
+ }
+ });
+ }
+ playCharLayerApi = { status: 'done', map };
+ })
+ .catch(() => {
+ playCharacterIdRoster = [];
+ playCharLayerApi = { status: 'done', map: Object.create(null) };
+ });
+ }
+
+ /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */
+ function playCharLayerFromApi(characterId) {
+ if (playCharLayerApi.status !== 'done' || !characterId) return null;
+ if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) {
+ return playCharLayerApi.map[characterId];
+ }
+ return null;
+ }
+
+ /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */
+ function playCharDiskLayersSet(characterId) {
+ if (!characterId || playCharLayerApi.status !== 'done') return null;
+ const s = playCharDiskLayersById[characterId];
+ return s && s.size ? s : null;
+ }
+
+ const playLayerImageCache = new Map();
+ const playLayerCompositeCache = new Map();
+ function pickRandomPlayTint() {
+ return {
+ head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)],
+ hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)],
+ body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)],
+ };
+ }
+
+ /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */
+ function hashStringFnv1a32(str) {
+ let h = 2166136261 >>> 0;
+ const s = String(str || '');
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+ }
+
+ function playTintFromPeerId(id) {
+ const s = String(id || 'x');
+ const h0 = hashStringFnv1a32(s);
+ const h1 = hashStringFnv1a32(s + '\n1hair');
+ const h2 = hashStringFnv1a32(s + '\n2body');
+ return {
+ head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length],
+ hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length],
+ body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length],
+ };
+ }
+
+ const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
+ /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */
+ const PLAY_LOBBY_THEME_HEX = [
+ '#d72520', '#ef8508', '#efe237', '#5bb443',
+ '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe',
+ ];
+ const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ let playLobbyBotThemes = [];
+
+ function parsePlayLobbyThemeIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 8) ? v : null;
+ }
+
+ function parsePlayLobbySkinIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 3) ? v : null;
+ }
+
+ function playTintFromLobbyThemeIndices(themeIndex, skinIndex) {
+ const ti = parsePlayLobbyThemeIndex(themeIndex);
+ const si = parsePlayLobbySkinIndex(skinIndex);
+ const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : '';
+ const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : '';
+ if (!bodyHex && !headHex) return null;
+ return playTintFromLobbyRgb(bodyHex || null, headHex || null);
+ }
+
+ function playTintFromJoinPeer(peerRow) {
+ if (!peerRow) return null;
+ return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex);
+ }
+
+ function playTintForPreviewBotId(peerId) {
+ if (!isPreviewBotId(peerId)) return null;
+ const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10);
+ if (!Number.isFinite(idx) || idx < 0) return null;
+ const slot = playLobbyBotThemes[idx];
+ if (!slot) return null;
+ const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex);
+ if (!tint) return null;
+ try {
+ const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}');
+ map['__lobby_bot_' + idx] = {
+ colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '',
+ colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '',
+ };
+ sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map));
+ } catch (e) { /* ignore */ }
+ return tint;
+ }
+
+ function lobbyBotTintsStorageKeyPlay() {
+ const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default');
+ return 'justiceLobbyBotTints:' + sp;
+ }
+
+ function rgbCssToHexPlay(rgb) {
+ if (!rgb) return '';
+ const s = String(rgb).trim();
+ if (s.charAt(0) === '#') return s;
+ const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
+ if (!m) return '';
+ return '#' + [m[1], m[2], m[3]].map((n) => {
+ const v = Math.max(0, Math.min(255, parseInt(n, 10)));
+ return v.toString(16).padStart(2, '0');
+ }).join('');
+ }
+
+ function playTintFromLobbyRgb(themeRgb, skinRgb) {
+ const bodyHex = rgbCssToHexPlay(themeRgb);
+ const headHex = rgbCssToHexPlay(skinRgb);
+ if (!headHex && !bodyHex) return null;
+ return {
+ head: headHex || '#fbd5c4',
+ hair: bodyHex || '#fb4941',
+ body: bodyHex || '#fb4941',
+ };
+ }
+
+ function loadPlayTintFromLobbyPlayerStorage() {
+ try {
+ const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
+ if (raw) {
+ const j = JSON.parse(raw);
+ if (j && typeof j === 'object') {
+ if (j.head && j.body) {
+ return {
+ head: String(j.head),
+ hair: String(j.hair || j.body),
+ body: String(j.body),
+ };
+ }
+ const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb);
+ if (fromJson) return fromJson;
+ }
+ }
+ } catch (e) { /* ignore */ }
+ try {
+ const theme = localStorage.getItem('lobbyThemeColor') || '';
+ const skin = localStorage.getItem('lobbySkinTone') || '';
+ return playTintFromLobbyRgb(theme, skin);
+ } catch (e2) {
+ return null;
+ }
+ }
+
+ function loadPlayTintForBotPeerId(peerId) {
+ const sid = String(peerId || '');
+ let botKey = sid;
+ if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) {
+ botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length);
+ }
+ try {
+ const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay());
+ if (!raw) return null;
+ const map = JSON.parse(raw);
+ const saved = map && map[botKey];
+ if (!saved) return null;
+ return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */
+ function resolvePlayTintForPeer(peerId, peerRow) {
+ const sid = String(peerId || '');
+ const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null;
+ if (fromRow) return fromRow;
+ const previewTint = playTintForPreviewBotId(sid);
+ if (previewTint) return previewTint;
+ if (myId != null && sid === String(myId)) {
+ const meTint = loadPlayTintFromLobbyPlayerStorage();
+ if (meTint) return meTint;
+ }
+ if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint;
+ const botTint = loadPlayTintForBotPeerId(sid);
+ if (botTint) return botTint;
+ return playTintFromPeerId(sid);
+ }
+
+ function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) {
+ const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
+ if (!tint) return;
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ me.playTint = tint;
+ return;
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o) o.playTint = tint;
+ if (isPreviewBotId(sid)) playTintForPreviewBotId(sid);
+ }
+
+ function applyPlayLobbyTintSync(data) {
+ if (!data || typeof data !== 'object') return;
+ if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes;
+ if (Array.isArray(data.peers)) {
+ data.peers.forEach(function (p) {
+ if (!p || p.id == null) return;
+ applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex);
+ });
+ }
+ if (playBotsEnabled()) rebalancePreviewBots();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function getPlayTintForMissionAvatar(peerId) {
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ return me.playTint || resolvePlayTintForPeer(sid);
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o && o.playTint) return o.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */
+ function missionEnsureHumanInTopRanked(rows, maxN) {
+ const cap = Math.max(1, maxN || 5);
+ if (!Array.isArray(rows) || !rows.length) return [];
+ const humanId = myId != null ? String(myId) : null;
+ const humanRow = humanId
+ ? rows.find((r) => r && r.id != null && String(r.id) === humanId)
+ : rows.find((r) => r && r.isLocalHuman);
+ let top = rows.slice(0, cap);
+ if (!humanRow) return top;
+ const hid = humanRow.id != null ? String(humanRow.id) : '';
+ if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top;
+ if (top.length >= cap) top = top.slice(0, cap - 1);
+ top.push(humanRow);
+ return top;
+ }
+
+ function hexToRgb01(hex) {
+ const h = (hex || '').replace('#', '').trim();
+ if (h.length !== 6) return [1, 1, 1];
+ return [
+ parseInt(h.slice(0, 2), 16) / 255,
+ parseInt(h.slice(2, 4), 16) / 255,
+ parseInt(h.slice(4, 6), 16) / 255,
+ ];
+ }
+
+ function tintPlayLayerImageData(imageData, tintHex) {
+ const rgb = hexToRgb01(tintHex);
+ const tr = rgb[0], tg = rgb[1], tb = rgb[2];
+ const d = imageData.data;
+ for (let i = 0; i < d.length; i += 4) {
+ if (d[i + 3] < 12) continue;
+ const r = d[i], g = d[i + 1], b = d[i + 2];
+ const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ d[i] = Math.min(255, Math.round(tr * 255 * L));
+ d[i + 1] = Math.min(255, Math.round(tg * 255 * L));
+ d[i + 2] = Math.min(255, Math.round(tb * 255 * L));
+ }
+ }
+
+ function drawPlayTintedLayer(ctx, w, h, img, tintHex) {
+ if (!tintHex) {
+ ctx.drawImage(img, 0, 0, w, h);
+ return;
+ }
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const x = c.getContext('2d');
+ x.drawImage(img, 0, 0, w, h);
+ try {
+ const idata = x.getImageData(0, 0, w, h);
+ tintPlayLayerImageData(idata, tintHex);
+ x.putImageData(idata, 0, 0);
+ } catch (e) {
+ x.clearRect(0, 0, w, h);
+ x.drawImage(img, 0, 0, w, h);
+ }
+ ctx.drawImage(c, 0, 0, w, h);
+ }
+
+ function ensurePlayLayerImage(url) {
+ let img = playLayerImageCache.get(url);
+ if (!img) {
+ img = new Image();
+ img.src = url;
+ playLayerImageCache.set(url, img);
+ }
+ return img;
+ }
+
+ /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม
+ * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */
+ const _playPreloadedChars = new Set();
+ const _playPreloadedTintKeys = new Set();
+ function preloadPlayCharacterLayers(charId, _tries) {
+ if (!charId || _playPreloadedChars.has(charId)) return;
+ try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ }
+ try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ }
+ const diskSet = playCharDiskLayersSet(charId);
+ if (!diskSet) {
+ // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404)
+ const t = _tries || 0;
+ if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450);
+ return;
+ }
+ _playPreloadedChars.add(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); };
+ dirs.forEach(function (dir) {
+ PLAY_LAYER_ORDER.forEach(function (ln) {
+ if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์
+ if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face
+ for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) {
+ warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi));
+ }
+ warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0));
+ });
+ });
+ }
+
+ /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */
+ function preloadPlayTintForAvatar(charId, tint) {
+ if (!charId || !tint || typeof tint.head !== 'string') return;
+ const key = [charId, tint.head, tint.hair, tint.body].join('|');
+ if (_playPreloadedTintKeys.has(key)) return;
+ _playPreloadedTintKeys.add(key);
+ preloadPlayCharacterLayers(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const now = Date.now();
+ dirs.forEach(function (dir) {
+ const rawWalk = getCharacterFrame(charId, dir, now, true);
+ const rawIdle = getCharacterFrame(charId, dir, now, false);
+ if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint);
+ if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint);
+ });
+ }
+
+ function preloadPlayOccupantsTints() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) {
+ preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))));
+ }
+ others.forEach(function (o, id) {
+ if (!o || !o.characterId) return;
+ preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id));
+ });
+ }
+
+ /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */
+ function layerUrlCandidates(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir;
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ add(base + '_layer_' + layerName + '.png');
+ return out;
+ }
+
+ /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */
+ function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว
+ add(base + '_layer_' + layerName + '.png');
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ return out;
+ }
+
+ function shadowUrlCandidates(id, dir, frameIndex) {
+ const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ function shadowUrlCandidatesIdle(id, dir, frameIndex) {
+ const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
+ function resolvePlayLayerImage(urls) {
+ for (let i = 0; i < urls.length; i++) {
+ const img = ensurePlayLayerImage(urls[i]);
+ if (!img.complete) return { status: 'pending' };
+ if (img.naturalWidth > 0) return { status: 'ok', img };
+ }
+ return { status: 'missing' };
+ }
+
+ function ensurePlayLayerProbesAllDirections(characterId) {
+ if (!characterId || playLayerAllDirsQueued[characterId]) return;
+ playLayerAllDirsQueued[characterId] = true;
+ ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
+ }
+
+ function playCharAnyDirectionLayered(characterId) {
+ if (!characterId) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
+ }
+
+ /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
+ function playCharLayerDiscoveryPending(characterId) {
+ if (!characterId) return true;
+ const api = playCharLayerFromApi(characterId);
+ if (api === true || api === false) return false;
+ if (playCharAnyDirectionLayered(characterId)) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => {
+ const m = playLayerMode[characterId + '|' + d];
+ return m === undefined || m === 'pending';
+ });
+ }
+
+ function ensurePlayLayerProbe(characterId, dir) {
+ const key = characterId + '|' + dir;
+ if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
+ if (playLayerMode[key] === 'pending') return;
+ playLayerMode[key] = 'pending';
+ const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
+ let ci = 0;
+ function tryNextProbe() {
+ if (playLayerMode[key] !== 'pending') return;
+ if (ci >= cands.length) {
+ playLayerMode[key] = 'none';
+ return;
+ }
+ const url = cands[ci++];
+ const img = ensurePlayLayerImage(url);
+ const fin = () => {
+ if (playLayerMode[key] !== 'pending') return;
+ if (img.naturalWidth > 0) {
+ playLayerMode[key] = 'layered';
+ return;
+ }
+ tryNextProbe();
+ };
+ img.onload = fin;
+ img.onerror = fin;
+ if (img.complete) fin();
+ }
+ tryNextProbe();
+ }
+
+ function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
+ if (!id) return 0;
+ const key = id + '_' + dir;
+ const anim = characterAnimations[key];
+ if (!anim) return 0;
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ return fi >= 0 ? fi : 0;
+ }
+
+ function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
+ const useIdle = opts && opts.idle;
+ if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
+ const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const cctx = c.getContext('2d');
+ let anyTintColorLayer = false;
+ let skippedShadowWhilePending = false;
+ const diskLayers = playCharDiskLayersSet(id);
+ /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */
+ const idleLayerStripes = !!useIdle;
+ const resDir = dir;
+ const resFi = frameIndex;
+ for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
+ const layerName = PLAY_LAYER_ORDER[li];
+ if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
+ /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */
+ if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') {
+ continue;
+ }
+ let urls;
+ if (layerName === 'shadow') {
+ urls = idleLayerStripes
+ ? shadowUrlCandidatesIdle(id, dir, frameIndex)
+ : shadowUrlCandidates(id, resDir, resFi);
+ } else {
+ urls = idleLayerStripes
+ ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex)
+ : layerUrlCandidates(id, resDir, layerName, resFi);
+ }
+ const r = resolvePlayLayerImage(urls);
+ /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
+ if (r.status === 'pending' && layerName === 'shadow') {
+ skippedShadowWhilePending = true;
+ continue;
+ }
+ if (r.status === 'pending') return null;
+ if (r.status === 'missing') continue;
+ const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
+ if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
+ drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
+ }
+ /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
+ if (!anyTintColorLayer) return null;
+ return { canvas: c, skipCache: skippedShadowWhilePending };
+ }
+
+ function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
+ if (!tint || !characterId || !rawImg) return rawImg;
+ ensurePlayCharLayerListFetch();
+ ensurePlayLayerProbesAllDirections(characterId);
+ const dirn = dir || 'down';
+ const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
+ const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
+
+ if (playCharLayerDiscoveryPending(characterId)) return rawImg;
+
+ const apiFlag = playCharLayerFromApi(characterId);
+ /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
+ const charHasLayerFiles = apiFlag === true
+ || (apiFlag == null && playCharAnyDirectionLayered(characterId));
+
+ /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */
+ if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) {
+ return rawImg;
+ }
+ const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
+
+ if (charHasLayerFiles) {
+ if (!isWalking) {
+ const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase;
+ const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
+ const hitI = playLayerCompositeCache.get(cacheKeyIdle);
+ if (hitI) return hitI;
+ const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true });
+ if (packI && packI.canvas) {
+ if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
+ return packI.canvas;
+ }
+ }
+ const hit = playLayerCompositeCache.get(cacheKeyWalk);
+ if (hit) return hit;
+ const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
+ if (pack && pack.canvas) {
+ if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
+ return pack.canvas;
+ }
+ return rawImg;
+ }
+
+ /*
+ * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม)
+ */
+ return rawImg;
+ }
+
+ function getCharacterImg(id, direction) {
+ if (!id) return null;
+ const key = id + '_' + (direction || 'down');
+ if (characterImages[key]) return characterImages[key];
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
+ characterImages[key] = img;
+ return img;
+ }
+
+ function getCharacterFrame(id, direction, now, isWalking) {
+ if (!id) return null;
+ const dir = direction || 'down';
+ const t = typeof now === 'number' ? now : Date.now();
+ if (!isWalking) {
+ const idleAnim = ensureCharacterIdleAnim(id, dir);
+ let idlePhase = idleAnimPhaseIndex(t);
+ if (isUploadedCharAssetId(id)) idlePhase = 0;
+ const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
+ if (idleFi >= 0) return idleAnim.frames[idleFi];
+ const idfb = idleAnim.fallback;
+ if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
+ }
+ const key = id + '_' + dir;
+ let anim = characterAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterAnimations[key] = anim;
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ anim.fallback = getCharacterImg(id, dir);
+ }
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ if (fi >= 0) return anim.frames[fi];
+ const fb = anim.fallback;
+ if (fb && fb.complete && fb.naturalWidth) return fb;
+ return null;
+ }
+
+ function getAvatarImg(characterId, direction, now, isWalking) {
+ const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
+ if (img) return img;
+ return defaultAvatarImg;
+ }
+
+ /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */
+ const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:';
+ const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:';
+ const playLobbyIdleAvatarCache = new Map();
+
+ function playLobbyIdleAvatarSrcForCharacter(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return '';
+ try {
+ const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || '';
+ if (ss && ss.indexOf('data:image/') === 0) return ss;
+ const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || '';
+ if (ls && ls.indexOf('data:image/') === 0) return ls;
+ } catch (e) { /* ignore */ }
+ return '';
+ }
+
+ function ensurePlayLobbyIdleAvatarImage(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return;
+ if (playLobbyIdleAvatarCache.get(id + '_loading')) return;
+ const src = playLobbyIdleAvatarSrcForCharacter(id);
+ if (!src) return;
+ const img = new Image();
+ img.onload = function () {
+ playLobbyIdleAvatarCache.set(id, img);
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.onerror = function () {
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.src = src;
+ playLobbyIdleAvatarCache.set(id + '_loading', img);
+ }
+
+ function getPlayLobbyIdleAvatarImageSync(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return null;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return hit;
+ ensurePlayLobbyIdleAvatarImage(id);
+ return null;
+ }
+
+ /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */
+ function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) {
+ const dir = direction || 'down';
+ const walk = !!isWalking;
+ if (dir === 'down' && useLobbyIdleComposite && !walk) {
+ const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId);
+ if (lobbyImg) return lobbyImg;
+ }
+ const rawImg = getAvatarImg(characterId, dir, now, walk);
+ if (!playTint || !characterId) return rawImg;
+ return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg;
+ }
+
+ function preloadPlayLobbyIdleAvatarsForOccupants() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) ensurePlayLobbyIdleAvatarImage(meCid);
+ others.forEach(function (o) {
+ if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId);
+ });
+ }
+
+ function playHideTopChrome() {
+ try {
+ return document.documentElement.classList.contains('play-hide-top-chrome');
+ } catch (e) {
+ return true;
+ }
+ }
+
+ function getStoredCharacterId() {
+ try {
+ const v = localStorage.getItem('gameCharacterId');
+ if (v) return v;
+ } catch (e) { /* ignore */ }
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ function getPlayCharacterId() {
+ if (forceDefaultCharacter) {
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ return getStoredCharacterId();
+ }
+
+ /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */
+ function getJdPlayerKey() {
+ try {
+ let k = (localStorage.getItem('jdPlayerKey') || '').trim();
+ if (!k || k.length < 8) {
+ k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ localStorage.setItem('jdPlayerKey', k);
+ }
+ return k;
+ } catch (e) {
+ return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ }
+ }
+
+ /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */
+ function pickPreviewBotCharacterId(botSlotIndex) {
+ const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0);
+ const roster = playCharacterIdRoster;
+ const humanId = String(getPlayCharacterId() || '');
+ if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID;
+ const alts = roster.filter((id) => id !== humanId);
+ const pool = alts.length ? alts : roster.slice();
+ return pool[idx % pool.length] || humanId || roster[0];
+ }
+ const MOVE_SPEED = 0.15;
+ /** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */
+ const QUIZ_CARRY_WALK_SPEED_MULT = 1.42;
+ /** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */
+ let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ const PATH_ARRIVE_THRESH = 0.15;
+
+ function clampQuizCarryWalkSpeedMultClient(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return def;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+ }
+
+ function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) {
+ const base = QUIZ_CARRY_WALK_SPEED_MULT;
+ if (!s || typeof s !== 'object') return base;
+ const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim();
+ const mult = Number(s.carryWalkSpeedMult);
+ const mapId = (currentPlayMapId() || '').trim();
+ if (forId && mapId && forId === mapId && Number.isFinite(mult)) {
+ return clampQuizCarryWalkSpeedMultClient(mult, base);
+ }
+ return base;
+ }
+
+ function applyQuizCarryWalkSpeedFromSettingsObj(s) {
+ if (!isQuizCarry()) {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ return;
+ }
+ quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s);
+ }
+
+ function moveSpeedTilesThisFrameForWalk() {
+ return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1);
+ }
+
+ function isMovementKey(code) {
+ return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
+ }
+ function isChatFocused() {
+ return false;
+ }
+ let zoom = 1.4;
+ let froggerScore = 0;
+ let lastFroggerKey = 0;
+ let gauntletObstacles = [];
+ /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
+ let gauntletObsRenderPrev = [];
+ let gauntletObsRenderNext = [];
+ let gauntletObsBlendT0 = 0;
+ let meGauntletJumpTicks = 0;
+ /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
+ let meGauntletJumpVis = 0;
+ /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
+ let gauntletRuntimeTickMs = 220;
+ let gauntletRuntimeJumpTicks = 16;
+ /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
+ let playStackSwingHz = 0.55;
+ /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
+ let playStackBlockWidthTiles = null;
+ /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */
+ let playStackTowerMissionTimeSec = 90;
+ /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */
+ let playStackTeamMissesMax = 3;
+ /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
+ let playStackTowerProgressBlocks = 50;
+ /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */
+ let playStackBlockNormalUrls = ['', '', '', '', '', ''];
+ let playStackBlockHeavyUrls = ['', '', '', '', '', ''];
+ /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */
+ let playStackHeavyBlockPercent = 35;
+ /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
+ let playJumpSurviveJumpHeightMult = 1.5;
+ /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */
+ let playJumpSurviveMissionTimeSec = 0;
+ /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
+ let playJumpSurvivePlatformTiles = [
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ ];
+ /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */
+ let playSpaceShooterMissionTimeSec = 0;
+ /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */
+ let playSpaceShooterShipImageUrls = ['', '', '', '', '', ''];
+ /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */
+ let playSpaceShooterAsteroidSpriteUrls = [];
+ let playSpaceShooterAsteroidExplodeFrameMs = 70;
+ /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
+ let playSpaceShooterAsteroidIntervalMs = 1040;
+ /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */
+ let playSpaceShooterShipDamageOverlayUrls = ['', '', ''];
+ /** balloon_boss / Mega Virus — จาก game-timing */
+ let playBalloonBossMissionTimeSec = 0;
+ let playBalloonBossBossImageUrl = '';
+ let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
+ let playBalloonBossPlayerBalloonFallbackUrl = '';
+ /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
+ let gauntletRuntimeTimeLimitSec = 0;
+ /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
+ let gauntletEndsAtMs = null;
+ /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */
+ let gauntletCrownRunHeldRemote = false;
+ /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */
+ let gauntletCrownPregamePhase = null;
+ let gauntletCrownLobbyReadyMap = {};
+ let gauntletCrownCountdownTimer = null;
+ let lastGauntletJumpKey = 0;
+ /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
+ let gauntletLaneImageUrls = [];
+ let gauntletLaserTopUrl = '';
+ let gauntletLaserBottomUrl = '';
+ let gauntletLaserLineUrl = '';
+ let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
+ let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
+ let gauntletLaserLineWidthPx = 2;
+ const gauntletAssetImageCache = new Map();
+
+ function encodeSpacesInUrlPath(t) {
+ const s = String(t || '');
+ const q = s.indexOf('?');
+ const pathPart = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ return pathPart.replace(/ /g, '%20') + query;
+ }
+
+ function decodeAssetUrlPercentRuns(t) {
+ let s = String(t || '');
+ const q = s.indexOf('?');
+ let base = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ for (let i = 0; i < 2; i += 1) {
+ try {
+ const d = decodeURIComponent(base);
+ if (d === base) break;
+ base = d;
+ } catch (e) {
+ break;
+ }
+ }
+ return base + query;
+ }
+
+ function normalizeGameAssetUrlForWebPlay(t) {
+ let s = String(t || '');
+ s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
+ s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
+ return s;
+ }
+
+ /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
+ function normalizeGauntletAssetUrlForPlay(u) {
+ if (typeof u !== 'string') return '';
+ let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim()));
+ const bare0 = raw.split('?')[0].replace(/\/+$/, '');
+ if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) {
+ raw = '/Game/img/MegaVirus/Artboard%209.png';
+ }
+ if (!raw) return '';
+ if (/^https?:\/\//i.test(raw)) {
+ const z = raw.replace(/\/+$/, '');
+ return z ? encodeSpacesInUrlPath(z) : '';
+ }
+ const qIdx = raw.indexOf('?');
+ const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, '');
+ const qs = qIdx >= 0 ? raw.slice(qIdx) : '';
+ if (!base) return '';
+ if (/\.{4,}/.test(base)) return '';
+ if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs);
+ if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs);
+ return encodeSpacesInUrlPath(base + qs);
+ }
+
+ function ensureGauntletAssetImage(url) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : '');
+ if (!u) return null;
+ let rec = gauntletAssetImageCache.get(u);
+ if (rec) return rec;
+ rec = { img: new Image(), ready: false };
+ if (/^https?:\/\//i.test(u)) {
+ try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ }
+ }
+ rec.img.onload = function () { rec.ready = true; };
+ rec.img.onerror = function () { rec.ready = false; };
+ rec.img.src = u;
+ /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */
+ if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true;
+ else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false;
+ gauntletAssetImageCache.set(u, rec);
+ return rec;
+ }
+
+ /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */
+ function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) {
+ const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim());
+ const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim());
+ if (per) {
+ const rPer = ensureGauntletAssetImage(per);
+ const img = rPer && rPer.img;
+ if (img && img.complete && img.naturalWidth > 0) return rPer;
+ if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb);
+ return rPer;
+ }
+ if (fb) return ensureGauntletAssetImage(fb);
+ return null;
+ }
+
+ function pickGauntletLaneImageRec(obsId) {
+ if (!gauntletLaneImageUrls.length) return null;
+ const n = gauntletLaneImageUrls.length;
+ const idx = Math.abs(Number(obsId) | 0) % n;
+ const u = gauntletLaneImageUrls[idx];
+ return u ? ensureGauntletAssetImage(u) : null;
+ }
+
+ function clampClientLaserColor(s, def) {
+ const t = String(s || '').trim().slice(0, 100);
+ if (!t || /[<>"'`]/.test(t)) return def;
+ return t;
+ }
+
+ let playQuizPhaseLocal = null;
+ let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ let playQuizText = '';
+ let playQuizPhaseEndsAt = 0;
+ let playQuizTimerInterval = null;
+ /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */
+ let playQuizQuestionIndex = 0;
+ let playQuizQuestionTotal = 0;
+ /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
+ let previewQuizPool = [];
+ /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */
+ let previewQuizQIndex = 0;
+ let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
+ let previewQuizStep = 'read';
+ let previewQuizCurrent = null;
+ let playLiveQuizScores = {};
+ /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */
+ let playQuizEverWrong = {};
+ /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */
+ const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png';
+ let quizWrongGhostImg = null;
+ function getQuizWrongGhostImg() {
+ if (quizWrongGhostImg) return quizWrongGhostImg;
+ const img = new Image();
+ img.decoding = 'async';
+ img.src = QUIZ_WRONG_GHOST_URL;
+ quizWrongGhostImg = img;
+ return img;
+ }
+ /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */
+ function quizGhostAreaGridPlay() {
+ const g = mapData && mapData.quizGhostArea;
+ if (!Array.isArray(g)) return null;
+ for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; }
+ return null;
+ }
+ function quizMeIsGhostPlay() {
+ return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse));
+ }
+ function quizGhostConfineActivePlay() {
+ return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay();
+ }
+ /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */
+ function quizGhostFootprintInsidePlay(x, y) {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return true;
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(','); const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+ /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */
+ function warpMeIntoQuizGhostZonePlay() {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return false;
+ if (quizGhostFootprintInsidePlay(me.x, me.y)) return true;
+ const xs = [], ys = [];
+ for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } }
+ if (!xs.length) return false;
+ const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2);
+ const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2);
+ const cand = [];
+ for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]);
+ cand.sort((a, b) => a[2] - b[2]);
+ let tx = xs[0], ty = ys[0];
+ for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } }
+ me.x = tx; me.y = ty; me.tx = tx; me.ty = ty;
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ return true;
+ }
+
+ /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */
+ function isQuizWrongGhostActiveForEnt(entId) {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ const sid = String(entId == null ? '' : entId);
+ const isMeEnt = (myId != null && sid === String(myId));
+ return isMeEnt
+ ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse))
+ : !!playQuizEverWrong[sid];
+ }
+
+ /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย
+ * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller)
+ * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px)
+ * - syBottom = พิกัด Y เท้า avatar บนจอ (px)
+ * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี
+ */
+ function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) {
+ const img = getQuizWrongGhostImg();
+ if (!img || !img.complete || !img.naturalWidth) return;
+ /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */
+ const hPx = Math.max(24, Number(heightPx) || 0);
+ const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
+ const wPx = hPx * ratio;
+ const sid = String(entId == null ? '' : entId);
+ const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04);
+ /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */
+ const drawX = sxCenter - wPx / 2;
+ const drawY = syBottom - hPx + tBob;
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.drawImage(img, drawX, drawY, wPx, hPx);
+ ctx.restore();
+ /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */
+ if (nameLabel) {
+ ctx.save();
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(255,255,255,0.95)';
+ ctx.strokeStyle = 'rgba(0,0,0,0.85)';
+ ctx.lineWidth = 3;
+ const ny = syBottom + 14;
+ ctx.strokeText(nameLabel, sxCenter, ny);
+ ctx.fillText(nameLabel, sxCenter, ny);
+ ctx.restore();
+ }
+ }
+ /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope)
+ * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */
+ function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) {
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cxWorld = (axTile + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ayTile + ch) * tileSize;
+ const [sx, sy] = w2s(cxWorld, cyBottomWorld);
+ /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */
+ const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35;
+ return { sx, sy, sizeH };
+ }
+ /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */
+ const QUIZ_TF_POINTS_PER_CORRECT = 10;
+ const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png';
+ const QUIZ_TF_SCORE_POPUP_MS = 1700;
+ /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */
+ const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7;
+ const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112;
+ let quizTfScorePopups = [];
+ let quizTfScorePlusImg = null;
+ /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
+ const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
+ /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */
+ const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000;
+ /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
+ let quizCarryPool = [];
+ let quizCarryCurrent = null;
+ /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
+ let quizCarryEmbedPendingQuestion = null;
+ let quizCarryEmbedCountdownStartAt = 0;
+ let quizCarryEmbedCountdownEndAt = 0;
+ /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */
+ let quizCarryEmbedPreOptionCountdownStartAt = 0;
+ let quizCarryEmbedPreOptionCountdownEndAt = 0;
+ /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
+ let quizCarryOptionRevealAt = 0;
+ let quizCarryAnswerCloseAt = 0;
+ /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */
+ let quizCarryAnswerTimeupAwaitNext = false;
+ let quizCarryAnswerRoundTimeupAutoT = null;
+ let quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
+ /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */
+ let quizCarrySessionLength = 0;
+ let quizCarryRoundsCompleted = 0;
+ let quizCarrySessionEnded = false;
+ /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */
+ let quizCarryResultEndTimer = null;
+ /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */
+ let embedPreviewLobbyReturnTimer = null;
+ /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */
+ let quizCarrySessionCompleteResultToSummaryT = null;
+ /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */
+ let quizCarryPregameActive = false;
+ let playHostId = null;
+ /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */
+ let quizCarryLobbyReadyMap = {};
+ /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */
+ let quizCarryMapPanelTheme = null;
+ /** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */
+ let quizMapPanelTheme = null;
+ /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */
+ let quizCarryEmbedCountdownTheme = null;
+ /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */
+ let quizCarryChoicePlaqueThemes = null;
+ /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */
+ let quizCarryPlaqueMapScale = 1.25;
+ const quizCarryChoiceImageCache = new Map();
+ /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */
+ let quizCarryJoinSettingsSnap = null;
+
+ /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */
+ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
+
+ /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */
+ let jumpSurviveCamCenterX = 0;
+ let jumpSurviveCamCenterY = 0;
+ let jumpSurviveEliminated = false;
+ /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
+ let jumpSurviveSessionStartMs = 0;
+ let jumpSurvivePlatformScrollPx = 0;
+ let jumpSurviveVy = 0;
+ let jumpSurviveLastTickMs = 0;
+ let jumpSurviveJumpQueued = false;
+ let jumpSurviveOnGround = false;
+ let jumpSurviveLastEmitT = 0;
+ /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */
+ let jumpSurviveMissionPhase = null;
+ let jumpSurviveGameEnded = false;
+ let jumperMissionCountdownTimer = null;
+
+ /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
+ let spaceShooterBullets = [];
+ let spaceShooterAsteroids = [];
+ /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */
+ let spaceShooterAsteroidExplosions = [];
+ let spaceShooterPopups = [];
+ let spaceShooterLastTickMs = 0;
+ let spaceShooterSpawnAccMs = 0;
+ let spaceShooterFireCd = 0;
+ let spaceShooterSessionStartMs = 0;
+ let spaceShooterLastMoveEmit = 0;
+ let spaceShooterGameEnded = false;
+ let spaceShooterMissionPhase = null;
+ let spaceShooterMissionCountdownTimer = null;
+
+ /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */
+ let quizQuestionMissionPhase = null;
+ let quizQuestionMissionCountdownTimer = null;
+ let quizQuestionMissionDeferredPhase = null;
+ /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */
+ let quizQuestionMissionJoyVecX = 0;
+ let quizQuestionMissionJoyVecY = 0;
+ let quizQuestionMissionJoyPointerId = null;
+ /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */
+ let stackTowerMissionPhase = null;
+ let stackTowerMissionCountdownTimer = null;
+ let stackTowerMissionDeferredPhase = null;
+ let stackTowerSessionStartAt = 0;
+ let stackTowerMissionEndedOnce = false;
+ /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */
+ let stackTowerPost50AnimStartMs = null;
+ /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */
+ let stackTowerResultFlashTimer = null;
+ const STACK_TOWER_RESULT_FLASH_MS = 5000;
+
+ /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */
+ function spaceShooterAsteroidLiveSpriteIndexPlay(a) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || !urls.length) return 0;
+ const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp);
+ if (hp >= maxHp) return 0;
+ if (urls.length >= 3) return 1;
+ return 0;
+ }
+
+ /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */
+ function spaceShooterAsteroidExplosionStartFiPlay() {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return 0;
+ return urls.length >= 3 ? 1 : 0;
+ }
+
+ function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return;
+ spaceShooterAsteroidExplosions.push({
+ x: worldX,
+ y: worldY,
+ r: Math.max(6, Number(r) || 14),
+ fi: spaceShooterAsteroidExplosionStartFiPlay(),
+ acc: 0,
+ });
+ }
+
+ function spaceShooterTickAsteroidExplosions(dt) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) {
+ spaceShooterAsteroidExplosions.length = 0;
+ return;
+ }
+ const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70));
+ const maxFi = urls.length - 2;
+ for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) {
+ const ex = spaceShooterAsteroidExplosions[i];
+ ex.acc += dt * 1000;
+ while (ex.acc >= fms) {
+ ex.acc -= fms;
+ ex.fi++;
+ if (ex.fi > maxFi) {
+ spaceShooterAsteroidExplosions.splice(i, 1);
+ break;
+ }
+ }
+ }
+ }
+
+ /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
+ let balloonBossPendingShots = [];
+ let balloonBossPlayerBullets = [];
+ let balloonBossBossBullets = [];
+ let balloonBossSessionStartMs = 0;
+ let balloonBossLastTickMs = 0;
+ let balloonBossLastMoveEmit = 0;
+ let balloonBossPlayerFireCd = 0;
+ let balloonBossGameEnded = false;
+ let balloonBossHitFx = [];
+ /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */
+ let balloonBossScorePopups = [];
+ let balloonBossBossFireAcc = 0;
+ /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
+ let quizBattleMcqPool = [];
+ let quizBattleAnsweredComps = new Set();
+ let quizBattleModalCompId = null;
+ let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง)
+ /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */
+ let quizBattleAnswerLog = new Map();
+ /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */
+ let quizBattleSessionStartMs = 0;
+ let quizBattleScoreSubmittedAt = 0;
+ /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */
+ let quizBattleChoiceLocked = false;
+ /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */
+ let quizBattleCompletedShown = false;
+ /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */
+ let quizBattleFinishLock = false;
+ /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */
+ let quizBattlePassedComps = new Set();
+ /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */
+ let quizBattleUserZoomMul = 1;
+ const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
+
+ document.getElementById('room-id').textContent = spaceId;
+
+ function hidePlayQuizHud() {
+ const sb = document.getElementById('play-quiz-scoreboard');
+ if (sb) sb.classList.add('is-hidden');
+ const fb = document.getElementById('play-quiz-feedback');
+ if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
+ }
+
+ function renderPlayQuizScoreboard(scores) {
+ const wrap = document.getElementById('play-quiz-scoreboard');
+ const ul = document.getElementById('play-quiz-scoreboard-list');
+ if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return;
+ if (!scores || typeof scores !== 'object') return;
+ wrap.classList.remove('is-hidden');
+ ul.textContent = '';
+ const merged = { ...scores };
+ others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
+ if (myId != null && merged[myId] == null) merged[myId] = 0;
+ const rows = [];
+ if (myId != null) {
+ rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
+ }
+ others.forEach((o, id) => {
+ rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
+ });
+ rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
+ rows.forEach((row) => {
+ const li = document.createElement('li');
+ if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
+ const spN = document.createElement('span');
+ spN.className = 'play-quiz-scoreboard-name';
+ spN.textContent = row.nick;
+ const spV = document.createElement('span');
+ spV.className = 'play-quiz-scoreboard-val';
+ spV.textContent = String(row.sc);
+ li.appendChild(spN);
+ li.appendChild(spV);
+ ul.appendChild(li);
+ });
+ if (isQuizCarry() && useCyberPlayHud()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */
+ if (isQuizQuestionMissionUiMapPlay()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */
+ if (isQuizBattle()) {
+ wrap.classList.add('is-hidden');
+ }
+ }
+
+ function initPlayLiveQuizScoresZeros() {
+ if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return;
+ playLiveQuizScores = {};
+ playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (isQuiz()) playQuizEverWrong = {};
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ function showPlayQuizFeedback(r) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el || !r || !r.results || myId == null) return;
+ let mine = null;
+ let botRight = 0, botWrong = 0;
+ for (let i = 0; i < r.results.length; i++) {
+ const row = r.results[i];
+ if (row.id === myId) mine = row;
+ else if (isPreviewBotId(row.id)) {
+ if (row.right) botRight++;
+ else botWrong++;
+ }
+ }
+ if (!mine) return;
+ el.classList.remove('is-hidden');
+ const botExtra = playBotsEnabled() && (botRight + botWrong > 0)
+ ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
+ : '';
+ if (mine.right) {
+ el.className = 'play-quiz-feedback play-quiz-feedback-ok';
+ el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra;
+ } else {
+ el.className = 'play-quiz-feedback play-quiz-feedback-bad';
+ el.textContent = (mine.choice == null
+ ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด'
+ : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra;
+ }
+ if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT);
+ window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200);
+ }
+
+ function ensureQuizTfScorePlusImgPlay() {
+ if (quizTfScorePlusImg && quizTfScorePlusImg.src) return;
+ quizTfScorePlusImg = new Image();
+ quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL;
+ quizTfScorePlusImg.onerror = function () {
+ this.onerror = null;
+ };
+ }
+
+ /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */
+ function spawnQuizTrueFalseScorePopupPlay(correctTrue) {
+ if (!isQuiz() || !mapData) return;
+ const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea;
+ const b = getTileBoundsForOnesGrid(grid);
+ if (!b) return;
+ const ts = tileSize || 32;
+ const wx = ((b.minX + b.maxX + 1) / 2) * ts;
+ const wy = ((b.minY + b.maxY + 1) / 2) * ts;
+ const now = Date.now();
+ ensureQuizTfScorePlusImgPlay();
+ quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS });
+ }
+
+ function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!quizTfScorePopups.length) return;
+ const img = quizTfScorePlusImg;
+ if (!img || !img.complete || !img.naturalWidth) return;
+ const now = typeof timeMs === 'number' ? timeMs : Date.now();
+ quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now);
+ const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT);
+ for (let i = 0; i < quizTfScorePopups.length; i++) {
+ const p = quizTfScorePopups[i];
+ const span = Math.max(1, p.untilMs - p.startMs);
+ const t = Math.max(0, Math.min(1, (now - p.startMs) / span));
+ const [sx, sy] = worldToScreen(p.wx, p.wy);
+ const rise = t * 56 * zDraw;
+ const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28);
+ const pulse = 1 + Math.sin(t * Math.PI) * 0.1;
+ const scale = (baseW / img.naturalWidth) * pulse;
+ const w = img.naturalWidth * scale;
+ const h = img.naturalHeight * scale;
+ ctx.save();
+ ctx.globalAlpha = fade;
+ ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h);
+ ctx.restore();
+ }
+ }
+
+ function getTileBoundsForOnesGrid(grid) {
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ function getQuizQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */
+ function getQuizCarryQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ function clearQuizMapPanelThemeInline(panel, textEl) {
+ if (!panel || !textEl) return;
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ const qKick = document.getElementById('quiz-map-question-kicker');
+ if (qKick) {
+ qKick.textContent = '';
+ qKick.classList.add('is-hidden');
+ qKick.setAttribute('aria-hidden', 'true');
+ }
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ panel.style.removeProperty('--qmap-bg');
+ panel.style.removeProperty('--qmap-border');
+ panel.style.removeProperty('--qmap-border-w');
+ panel.style.removeProperty('--qmap-shadow');
+ panel.style.removeProperty('background');
+ panel.style.removeProperty('border');
+ panel.style.removeProperty('border-color');
+ panel.style.removeProperty('border-width');
+ panel.style.removeProperty('border-style');
+ panel.style.removeProperty('box-shadow');
+ textEl.style.removeProperty('--qmap-text');
+ textEl.style.removeProperty('--qmap-text-shadow');
+ textEl.style.removeProperty('color');
+ textEl.style.removeProperty('text-shadow');
+ textEl.style.removeProperty('font-size');
+ textEl.style.removeProperty('line-height');
+ textEl.style.removeProperty('width');
+ textEl.style.removeProperty('box-sizing');
+ textEl.style.removeProperty('max-height');
+ textEl.style.removeProperty('overflow');
+ }
+
+ function clearQuizMapQuestionCarryFeedback() {
+ if (typeof window.__quizMapCarryFeedbackT === 'number') {
+ clearTimeout(window.__quizMapCarryFeedbackT);
+ window.__quizMapCarryFeedbackT = null;
+ }
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (icon) {
+ icon.classList.add('is-hidden');
+ icon.removeAttribute('src');
+ }
+ if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */
+ function showQuizMapQuestionCarryFeedback(isCorrect) {
+ if (!isQuizCarry()) return;
+ const panel = document.getElementById('quiz-map-question-panel');
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (!panel || !icon || panel.classList.contains('is-hidden')) return;
+ clearQuizMapQuestionCarryFeedback();
+ icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
+ icon.classList.remove('is-hidden');
+ if (isCorrect && scoreWrap && scoreEl) {
+ scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus;
+ scoreWrap.classList.remove('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'false');
+ scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ void scoreEl.offsetWidth;
+ scoreEl.classList.add('quiz-map-q-feedback-score--pop');
+ } else if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ window.__quizMapCarryFeedbackT = setTimeout(() => {
+ window.__quizMapCarryFeedbackT = null;
+ clearQuizMapQuestionCarryFeedback();
+ }, QUIZ_MAP_CARRY_FEEDBACK_MS);
+ }
+
+ /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */
+ function defaultCarryMapPanelThemePlay() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+ }
+
+ /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */
+ function quizCarryPlaqueMapScaleClampedPlay() {
+ const sc = Number(quizCarryPlaqueMapScale);
+ if (!Number.isFinite(sc)) return 1.25;
+ return Math.max(0.85, Math.min(2.5, sc));
+ }
+
+ /**
+ * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps))
+ * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max)
+ */
+ function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) {
+ const ts = tileSize * zoomVal;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const base = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const fb = defaultCarryMapPanelThemePlay();
+ let mn = Number(th && th.questionFontMinPx);
+ let mx = Number(th && th.questionFontMaxPx);
+ if (!Number.isFinite(mn)) mn = fb.questionFontMinPx;
+ if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx;
+ mn = Math.round(Math.max(8, Math.min(40, mn)));
+ mx = Math.round(Math.max(10, Math.min(72, mx)));
+ if (mx < mn) {
+ const s = mn;
+ mn = mx;
+ mx = s;
+ }
+ return Math.max(mn, Math.min(mx, base));
+ }
+
+ /**
+ * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar)
+ * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด
+ * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน
+ */
+ function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) {
+ const cs = window.getComputedStyle(panel);
+ const padT = parseFloat(cs.paddingTop) || 0;
+ const padB = parseFloat(cs.paddingBottom) || 0;
+ const padL = parseFloat(cs.paddingLeft) || 0;
+ const padR = parseFloat(cs.paddingRight) || 0;
+ let kickerReserve = 0;
+ if (panel.classList.contains('quiz-map-question-panel--question-mission-live')
+ && panel.classList.contains('quiz-map-question-panel--has-kicker')) {
+ kickerReserve = 30;
+ }
+ const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve);
+ const availW = Math.max(24, panel.clientWidth - padL - padR);
+ const themeMin = Math.max(8, Math.floor(Number(minPx) || 8));
+ const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7)));
+ let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin)));
+ textEl.style.setProperty('line-height', '1.28', 'important');
+ textEl.style.setProperty('width', '100%', 'important');
+ textEl.style.setProperty('box-sizing', 'border-box', 'important');
+ textEl.style.removeProperty('max-height');
+ textEl.style.setProperty('overflow', 'hidden', 'important');
+ for (let i = 0; i < 96 && px > hardMin; i++) {
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ const sh = textEl.scrollHeight;
+ const sw = textEl.scrollWidth;
+ if (sh <= availH + 2 && sw <= availW + 2) break;
+ px -= 1;
+ }
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ textEl.style.setProperty('max-height', availH + 'px', 'important');
+ }
+
+ /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */
+ function parseCarryMapPanelThemeObject(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = Number(t.questionFontMinPx);
+ let qMax = Number(t.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ return {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+ }
+
+ /**
+ * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง
+ * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24)
+ */
+ function parseCarryMapPanelThemeMapOverlay(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const out = {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ };
+ const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx');
+ const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx');
+ if (hasMin || hasMax) {
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx;
+ let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx;
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ out.questionFontMinPx = qMin;
+ out.questionFontMaxPx = qMax;
+ }
+ return out;
+ }
+
+ function setQuizCarryMapPanelThemeFromApi(s) {
+ quizCarryMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme);
+ if (!parsed) return;
+ quizCarryMapPanelTheme = parsed;
+ }
+
+ function setQuizMapPanelThemeFromApi(s) {
+ quizMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme);
+ if (!parsed) return;
+ quizMapPanelTheme = parsed;
+ }
+
+ function defaultCarryChoicePlaqueThemePlay() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ plaqueImageUrl: '',
+ };
+ }
+
+ function parseCarryChoicePlaqueThemeObject(raw) {
+ const d = defaultCarryChoicePlaqueThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const safeColor = (x, fb) => {
+ const t = String(x == null ? '' : x).trim().slice(0, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder),
+ fillBg: safeColor(raw.fillBg, d.fillBg),
+ textColor: safeColor(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl),
+ };
+ }
+
+ function buildCarryChoicePlaqueThemesArrayFromApi(s) {
+ if (!s || typeof s !== 'object') return null;
+ if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) {
+ out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i]));
+ }
+ return out;
+ }
+ if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') {
+ const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme);
+ return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one }));
+ }
+ return null;
+ }
+
+ function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) {
+ const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0));
+ if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) {
+ return quizCarryChoicePlaqueThemes[i];
+ }
+ return defaultCarryChoicePlaqueThemePlay();
+ }
+
+ function setQuizCarryChoicePlaqueThemeFromApi(s) {
+ quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s);
+ }
+
+ function sanitizeQuizCarryImageUrlClient(u) {
+ const s = String(u == null ? '' : u).trim().slice(0, 512);
+ if (!s || /[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const parsed = new URL(s);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+ }
+
+ /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */
+ function quizCarryApplyImageCrossOrigin(img, rawUrl) {
+ if (!img) return;
+ try {
+ img.removeAttribute('crossorigin');
+ const u = sanitizeQuizCarryImageUrlClient(rawUrl);
+ if (!u || u.startsWith('data:')) return;
+ let imgOrigin = '';
+ if (u.startsWith('http://') || u.startsWith('https://')) {
+ imgOrigin = new URL(u).origin;
+ } else if (u.startsWith('/')) {
+ imgOrigin = window.location.origin;
+ }
+ if (imgOrigin && imgOrigin !== window.location.origin) {
+ img.crossOrigin = 'anonymous';
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function preloadQuizCarryChoiceImages(q) {
+ if (!q) return;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ for (let i = 0; i < q.choiceImageUrls.length; i++) {
+ const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]);
+ if (!raw) continue;
+ if (quizCarryChoiceImageCache.has(raw)) continue;
+ const im = new Image();
+ quizCarryApplyImageCrossOrigin(im, raw);
+ im.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw, im);
+ try {
+ im.src = raw;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ if (Array.isArray(q.choices)) {
+ for (let j = 0; j < q.choices.length; j++) {
+ const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j);
+ if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue;
+ const im2 = new Image();
+ quizCarryApplyImageCrossOrigin(im2, raw2);
+ im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw2, im2);
+ try {
+ im2.src = raw2;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ }
+
+ function getQuizCarryChoiceImageCached(url) {
+ const u = sanitizeQuizCarryImageUrlClient(url);
+ if (!u) return null;
+ const im = quizCarryChoiceImageCache.get(u);
+ if (im && im.complete && im.naturalWidth > 0) return im;
+ return null;
+ }
+
+ function getQuizCarryChoiceImageUrlForIndex(q, idx) {
+ if (!q || !Array.isArray(q.choiceImageUrls)) return '';
+ const u = q.choiceImageUrls[idx];
+ return sanitizeQuizCarryImageUrlClient(u);
+ }
+
+ /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */
+ function getQuizCarryPlaqueImageUrlForIndex(q, idx) {
+ const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx);
+ if (perQ) return perQ;
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx);
+ const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : '';
+ return u || '';
+ }
+
+ function defaultCarryEmbedCountdownThemePlay() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+ }
+
+ function parseCarryEmbedCountdownThemeObject(raw) {
+ const d = defaultCarryEmbedCountdownThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ const clipStr = (x, maxLen) => {
+ const t = String(x == null ? '' : x).trim().slice(0, maxLen);
+ return t || null;
+ };
+ const safeColor = (x, fb) => {
+ const t = clipStr(x, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ return {
+ overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: safeColor(raw.innerBg, d.innerBg),
+ innerBorder: safeColor(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: safeColor(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+ }
+
+ function setQuizCarryEmbedCountdownThemeFromApi(s) {
+ quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme);
+ applyQuizCarryEmbedCountdownThemeToDom();
+ }
+
+ const CARRY_ECD_VAR_KEYS = [
+ '--carry-ecd-overlay',
+ '--carry-ecd-inner-bg',
+ '--carry-ecd-inner-border',
+ '--carry-ecd-inner-border-w',
+ '--carry-ecd-inner-radius',
+ '--carry-ecd-digit-color',
+ '--carry-ecd-map-digit-fs',
+ '--carry-ecd-screen-vw',
+ '--carry-ecd-screen-max',
+ ];
+
+ function clearQuizCarryEmbedCountdownThemeDomVars() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ if (!root) return;
+ CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } });
+ }
+
+ /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */
+ function applyQuizCarryEmbedCountdownThemeToDom() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+ if (!root) return;
+ clearQuizCarryEmbedCountdownThemeDomVars();
+ const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay();
+ root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop);
+ root.style.setProperty('--carry-ecd-inner-bg', th.innerBg);
+ root.style.setProperty('--carry-ecd-inner-border', th.innerBorder);
+ root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`);
+ root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`);
+ root.style.setProperty('--carry-ecd-digit-color', th.digitColor);
+ const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78)));
+ const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82)));
+ const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200)));
+ root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`);
+ const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28)));
+ const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132)));
+ root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`);
+ root.style.setProperty('--carry-ecd-screen-max', `${smx}px`);
+ if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') {
+ numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)';
+ } else if (numEl) {
+ try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ }
+ }
+ }
+
+ function setCountdown321QuestionAssetGraphic(numEl, n) {
+ if (!numEl) return;
+ const d = Math.max(1, Math.min(3, n));
+ if (String(numEl.tagName || '').toUpperCase() === 'IMG') {
+ numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png';
+ numEl.alt = String(d);
+ } else {
+ numEl.textContent = String(d);
+ }
+ }
+
+ /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */
+ function getEffectiveCarryMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.carryMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */
+ function getEffectiveQuizMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.quizMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ function paintQuizMapPanelTheme(panel, textEl, th) {
+ if (!panel || !textEl || !th) return;
+ clearQuizMapPanelThemeInline(panel, textEl);
+ const bgStr = th.panelBg != null ? String(th.panelBg).trim() : '';
+ const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : '';
+ const txStr = th.textColor != null ? String(th.textColor).trim() : '';
+ const wRaw = Number(th.borderWidthPx);
+ const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0;
+ const bgUse = bgStr || 'transparent';
+ const brUse = brStr || 'transparent';
+ const txUse = txStr || '#f1f5f9';
+ panel.style.setProperty('--qmap-bg', bgUse);
+ panel.style.setProperty('--qmap-border', brUse);
+ panel.style.setProperty('--qmap-border-w', String(w) + 'px');
+ panel.style.setProperty('--qmap-shadow', 'none');
+ textEl.style.setProperty('--qmap-text', txUse);
+ textEl.style.setProperty('--qmap-text-shadow', 'none');
+ /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */
+ panel.style.setProperty('background', bgUse, 'important');
+ panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important');
+ panel.style.setProperty('box-shadow', 'none', 'important');
+ textEl.style.setProperty('color', txUse, 'important');
+ textEl.style.setProperty('text-shadow', 'none', 'important');
+ }
+
+ function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuizCarry()) {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply());
+ }
+
+ function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuiz() || isQuizCarry()) return;
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply());
+ }
+
+ function syncPlayQuizMapPanel() {
+ const panel = document.getElementById('quiz-map-question-panel');
+ const textEl = document.getElementById('quiz-map-question-text');
+ if (!panel || !textEl || !mapData) return;
+ /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */
+ if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (isQuizBattle()) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (!isQuiz() && !isQuizCarry()) {
+ clearQuizMapQuestionCarryFeedback();
+ return;
+ }
+ const bounds = isQuiz()
+ ? getQuizQuestionAreaTileBounds(mapData)
+ : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData));
+ const text = (playQuizText || '').trim();
+ if (!bounds || !text) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ textEl.textContent = text;
+ panel.style.left = Math.round(left) + 'px';
+ panel.style.top = Math.round(top) + 'px';
+ panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ panel.classList.remove('is-hidden');
+ panel.setAttribute('aria-hidden', 'false');
+ if (isQuiz() && !isQuizCarry()) {
+ panel.classList.add('quiz-map-question-panel--true-false');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--true-false');
+ }
+ if (isQuizCarry()) {
+ applyQuizCarryMapPanelThemeIfNeeded(panel, textEl);
+ } else if (isQuiz()) {
+ applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl);
+ } else {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ }
+ const thFont = isQuizCarry()
+ ? getEffectiveCarryMapPanelThemeForApply()
+ : getEffectiveQuizMapPanelThemeForApply();
+ const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom);
+ const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10));
+ /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') {
+ panel.classList.add('quiz-map-question-panel--question-mission-live');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ if (panel.dataset.qmPlaqueTry) {
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ }
+ }
+ panel.classList.toggle(
+ 'quiz-map-question-panel--tf-mock-qz',
+ !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()),
+ );
+ const kickerEl = document.getElementById('quiz-map-question-kicker');
+ const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer';
+ if (kickerEl) {
+ let showK = false;
+ let line = '';
+ if (missionLiveQ && inReadOrAnswer) {
+ const qi = Math.max(0, Number(playQuizQuestionIndex) || 0);
+ const qt = Math.max(0, Number(playQuizQuestionTotal) || 0);
+ if (qi > 0 && qt > 0) {
+ line = '- Quiz ' + qi + ' / ' + qt + ' -';
+ showK = true;
+ } else if (qi > 0) {
+ line = '- Quiz ' + qi + ' -';
+ showK = true;
+ }
+ }
+ if (showK && line) {
+ kickerEl.textContent = line;
+ kickerEl.classList.remove('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'false');
+ panel.classList.add('quiz-map-question-panel--has-kicker');
+ } else {
+ kickerEl.textContent = '';
+ kickerEl.classList.add('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'true');
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ }
+ }
+ fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont);
+ }
+
+ /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */
+ function syncQuizCarryTimeupDeskLayerPosition() {
+ const layer = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!layer || layer.classList.contains('is-hidden')) return;
+ const anchor = layer.querySelector('.qc-timeup-desk-anchor')
+ || layer.querySelector('.qc-timeup-desk-inner')
+ || (() => {
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null;
+ })();
+ if (!anchor || !canvas) return;
+ const clearToFullStack = () => {
+ anchor.style.left = '0';
+ anchor.style.top = '0';
+ anchor.style.width = '100%';
+ anchor.style.height = '100%';
+ };
+ const stack = document.getElementById('play-canvas-stack');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (stack && panel && !panel.classList.contains('is-hidden')) {
+ try {
+ const sr = stack.getBoundingClientRect();
+ const pr = panel.getBoundingClientRect();
+ const rw = pr.width;
+ const rh = pr.height;
+ if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) {
+ anchor.style.left = Math.round(pr.left - sr.left) + 'px';
+ anchor.style.top = Math.round(pr.top - sr.top) + 'px';
+ anchor.style.width = Math.round(rw) + 'px';
+ anchor.style.height = Math.round(rh) + 'px';
+ return;
+ }
+ } catch (e) { /* fallback ด้านล่าง */ }
+ }
+ const pl = panel && panel.style && String(panel.style.left || '').trim();
+ const pt = panel && panel.style && String(panel.style.top || '').trim();
+ const pw = panel && panel.style && String(panel.style.width || '').trim();
+ const ph = panel && panel.style && String(panel.style.height || '').trim();
+ if (pl && pt && pw && ph) {
+ anchor.style.left = pl;
+ anchor.style.top = pt;
+ anchor.style.width = pw;
+ anchor.style.height = ph;
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'quiz_carry') {
+ clearToFullStack();
+ return;
+ }
+ const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ clearToFullStack();
+ return;
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ anchor.style.left = Math.round(left) + 'px';
+ anchor.style.top = Math.round(top) + 'px';
+ anchor.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ anchor.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ }
+
+ function carryEmbedCountdownAnchorResolved() {
+ if (!mapData || !isQuizCarry()) return 'screen';
+ const v = mapData.carryEmbedCountdownAnchor;
+ if (v === 'grid' || v === 'map') return v;
+ return 'screen';
+ }
+
+ function resetQuizCarryEmbedCountdownLayoutDom() {
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor');
+ if (inner) {
+ inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = '';
+ inner.style.left = '';
+ inner.style.top = '';
+ inner.style.width = '';
+ inner.style.height = '';
+ inner.style.transform = '';
+ inner.style.maxWidth = '';
+ inner.style.boxSizing = '';
+ inner.style.display = '';
+ inner.style.margin = '';
+ }
+ }
+
+ /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */
+ function syncQuizCarryEmbedCountdownLayout() {
+ if (!quizCarryEmbedMissionFlowActive() || !mapData) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ const canvasEl = document.getElementById('game-canvas');
+ if (!ov || !inner || !canvasEl) return;
+ if (ov.classList.contains('is-hidden')) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const anchor = carryEmbedCountdownAnchorResolved();
+ if (anchor === 'screen') {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ let bounds = null;
+ if (anchor === 'grid') {
+ bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ } else {
+ bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2;
+ const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ const r = canvasEl.getBoundingClientRect();
+ const sl = r.left + l;
+ const st = r.top + t;
+ ov.classList.add('quiz-carry-embed-countdown--map-anchor');
+ inner.classList.add('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = 'fixed';
+ inner.style.left = Math.round(sl) + 'px';
+ inner.style.top = Math.round(st) + 'px';
+ inner.style.width = Math.round(Math.max(100, wPx)) + 'px';
+ inner.style.height = Math.round(Math.max(72, hPx)) + 'px';
+ inner.style.transform = 'none';
+ inner.style.boxSizing = 'border-box';
+ inner.style.display = 'flex';
+ inner.style.flexDirection = 'column';
+ inner.style.alignItems = 'center';
+ inner.style.justifyContent = 'center';
+ inner.style.margin = '0';
+ inner.style.maxWidth = 'none';
+ }
+
+ /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
+ function syncQuizCarryEmbedQuestionStrip() {
+ const wrap = document.getElementById('quiz-carry-embed-q-strip');
+ const p = document.getElementById('quiz-carry-embed-q-strip-text');
+ if (!wrap || !p) return;
+ wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
+ wrap.classList.add('is-hidden');
+ wrap.setAttribute('aria-hidden', 'true');
+ }
+
+ function updatePlayQuizTimerDisplay() {
+ const el = document.getElementById('quiz-game-timer');
+ if (!el) return;
+ if (!playQuizPhaseEndsAt) {
+ el.textContent = '';
+ return;
+ }
+ const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
+ el.textContent = s + ' วินาที';
+ }
+
+ function clampPreviewMs(n, def, minV, maxV) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(minV, Math.min(maxV, Math.floor(v)));
+ }
+
+ function clampCarrySessionLen(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+ }
+
+ function buildQuizPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ return md.quizQuestions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+
+ function shufflePreviewQuizQuestionsPlay(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;
+ }
+
+ /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */
+ function clampPreviewQuizRoundQuestionCount(settings) {
+ const v = Number(settings && settings.quizRoundQuestionCount);
+ if (!Number.isFinite(v)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(v)));
+ }
+
+ function finishPreviewQuizSessionPlay() {
+ previewQuizStep = 'done';
+ playQuizPhaseLocal = null;
+ previewQuizCurrent = null;
+ playQuizPhaseEndsAt = 0;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) qov.classList.add('is-hidden');
+ const mapPanel = document.getElementById('quiz-map-question-panel');
+ if (mapPanel) {
+ mapPanel.classList.add('is-hidden');
+ mapPanel.setAttribute('aria-hidden', 'true');
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = 'ended';
+ applyQuizQuestionMissionPanelImages();
+ const mission = quizQuestionMissionBuildPayload();
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว';
+ if (qov) qov.classList.remove('is-hidden');
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)';
+ }
+
+ function clearPreviewBotAnswerPaths() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function resetPreviewBotsQuizState() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.quizCannotTrue = false;
+ o.quizCannotFalse = false;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function applyPreviewReadPhase(q, poolLen) {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'read';
+ playQuizPhaseLocal = 'read';
+ previewQuizCurrent = q;
+ playQuizText = q.text;
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs;
+ playQuizQuestionIndex = previewQuizQIndex + 1;
+ playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) {
+ phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen;
+ }
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ';
+ }
+
+ function applyPreviewAnswerPhase() {
+ previewQuizStep = 'answer';
+ playQuizPhaseLocal = 'answer';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg && previewQuizCurrent) {
+ leg.textContent = previewQuizCurrent.answerTrue
+ ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า'
+ : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู';
+ }
+ previewBotsPrepareAnswerRound();
+ }
+
+ function applyPreviewBetweenPhase() {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'between';
+ playQuizPhaseLocal = null;
+ playQuizText = 'ข้อถัดไปกำลังจะมา…';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)';
+ }
+
+ function quizCellOnPlay(grid, x, y) {
+ return !!(grid && grid[y] && grid[y][x] === 1);
+ }
+
+ /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */
+ function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) {
+ if (!mapData || !isQuiz()) return false;
+ for (const k of quizTilesFootprintPlay(ax, ay)) {
+ const parts = k.split(',');
+ const fx = +parts[0];
+ const fy = +parts[1];
+ if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true;
+ }
+ return false;
+ }
+
+ /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */
+ function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) {
+ if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false;
+ if (entId === myId || ent === me) {
+ return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse);
+ }
+ const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse);
+ const everWrong = !!playQuizEverWrong[String(entId)];
+ return locked || everWrong;
+ }
+
+ function spawnTileWalkablePlay(md, x, y) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ if (row && row[x] === 1) return false;
+ return true;
+ }
+
+ function spawnFootprintFitsPlay(md, anchorX, anchorY) {
+ if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false;
+ const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1)));
+ const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1)));
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const maxX = Math.min(w, anchorX + cellsW);
+ const maxY = Math.min(h, anchorY + cellsH);
+ for (let ty = anchorY; ty < maxY; ty++) {
+ for (let tx = anchorX; tx < maxX; tx++) {
+ if (!spawnTileWalkablePlay(md, tx, ty)) return false;
+ }
+ }
+ return true;
+ }
+
+ /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */
+ function pickRandomSpawnFromMapPlay(md) {
+ const fallback = md.spawn || { x: 1, y: 1 };
+ const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
+ const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return { x: fx, y: fy };
+ const u = new Uint32Array(1);
+ (typeof crypto !== 'undefined' && crypto.getRandomValues)
+ ? crypto.getRandomValues(u)
+ : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0));
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function parseLobbyPlayerSpawnsFromMapPlay(md) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const out = [null, null, null, null, null, null];
+ const raw = md && md.lobbyPlayerSpawns;
+ if (!Array.isArray(raw)) return out;
+ for (let i = 0; i < 6 && i < raw.length; i++) {
+ const cell = raw[i];
+ if (!cell || typeof cell !== 'object') continue;
+ const x = Math.floor(Number(cell.x));
+ const y = Math.floor(Number(cell.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+ if (x < 0 || x >= w || y < 0 || y >= h) continue;
+ out[i] = { x, y };
+ }
+ return out;
+ }
+
+ /** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */
+ function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) {
+ if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
+ const g = md.shooterSpawnSlots;
+ if (!g) return;
+ const w = md.width || 20, h = md.height || 15;
+ for (let slot = 1; slot <= 6; slot++) {
+ const idx = slot - 1;
+ if (slots6[idx]) continue;
+ let found = false;
+ for (let yy = 0; yy < h && !found; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] === slot) {
+ slots6[idx] = { x: xx, y: yy };
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */
+ function collectQuizBattleValidSpawnCentersPlay(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+ }
+
+ /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */
+ function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersPlay(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */
+ function collectQuizBattleSpawnPoolPlay(md) {
+ const out = [];
+ if (!md) return out;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActive(md)) {
+ if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue;
+ } else if (!spawnFootprintFitsPlay(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActive(md)) {
+ return collectQuizBattleValidSpawnCentersPlay(md);
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ return out;
+ }
+
+ function pickQuizBattleRandomSpawnWorldPlay(md) {
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y);
+ }
+ const u = new Uint32Array(1);
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u);
+ else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0);
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldPlay(md, sx, sy);
+ }
+ /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+
+ function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+ }
+
+ /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */
+ function pickSpawnForJoinPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1, y: 1 };
+ if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) {
+ const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex);
+ if (qb) return qb;
+ }
+ const mode = md.lobbySpawnMode;
+ const ord = joinOrderIndex | 0;
+ if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ if (mode === 'fixed' && md.spawn) {
+ const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
+ const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const x = Math.max(0, Math.min(w - 1, fx));
+ const y = Math.max(0, Math.min(h - 1, fy));
+ if (spawnFootprintFitsPlay(md, x, y)) return { x, y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots);
+ const pick = slots[j];
+ if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ return pickRandomSpawnFromMapPlay(md);
+ }
+
+ const GAUNTLET_PREVIEW_MAX = 6;
+ function gauntletSpawnYsPlay(md, playerCount) {
+ const h = md.height || 15;
+ const lo = 1;
+ const hi = Math.max(lo, h - 2);
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ if (hi <= lo) return Array.from({ length: n }, () => lo);
+ const ys = [];
+ for (let i = 0; i < n; i++) {
+ ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
+ }
+ return ys;
+ }
+ function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return [];
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
+ }
+ }
+ slots.sort((a, b) => a.y - b.y || a.x - b.x);
+ return slots;
+ }
+ function collectGauntletSpawnSlotsPlay(md) {
+ const explicit = md.gauntletPlayerSpawns;
+ if (Array.isArray(explicit) && explicit.length > 0) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (const raw of explicit) {
+ if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
+ const x = Math.floor(Number(raw && raw.x));
+ const y = Math.floor(Number(raw && raw.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
+ if (!spawnTileWalkablePlay(md, x, y)) continue;
+ slots.push({ x, y });
+ }
+ if (slots.length > 0) return slots;
+ }
+ return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
+ }
+ function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
+ const w = md.width || 20;
+ if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
+ if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
+ for (let d = 0; d < w; d++) {
+ if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
+ if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
+ }
+ return Math.max(0, Math.min(w - 1, spawnColX));
+ }
+ function gauntletSpawnPositionsPlay(md, playerCount) {
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ const slots = collectGauntletSpawnSlotsPlay(md);
+ const fallbackYs = gauntletSpawnYsPlay(md, n);
+ const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const y = i < slots.length
+ ? slots[i].y
+ : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
+ const slotX = i < slots.length ? slots[i].x : null;
+ const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
+ out.push({ x, y });
+ }
+ return out;
+ }
+ /**
+ * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
+ * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
+ */
+ function applyGauntletPreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ const humanCount = 1 + realIds.length;
+ const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
+ const pos = gauntletSpawnPositionsPlay(mapData, total);
+ if (onlyBots) {
+ let idx = humanCount;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ return;
+ }
+ let idx = 0;
+ if (idx < pos.length) {
+ me.x = pos[idx].x;
+ me.y = pos[idx].y;
+ me.tx = me.x;
+ me.ty = me.y;
+ idx++;
+ }
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ }
+
+ function isPreviewBotId(id) {
+ return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
+ }
+
+ function countPlayHumans() {
+ let n = 1;
+ others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
+ return n;
+ }
+
+ function rebalancePreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const human = countPlayHumans();
+ const wantBots = Math.max(0, playBotTargetHeadcount() - human);
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid);
+ if (o.botTier == null) {
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ o.botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ }
+ if (o.quizCannotTrue == null) o.quizCannotTrue = false;
+ if (o.quizCannotFalse == null) o.quizCannotFalse = false;
+ if (!Array.isArray(o.botPath)) o.botPath = [];
+ if (o.botAnswerWander == null) o.botAnswerWander = false;
+ if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ if (o.gauntletScore == null) {
+ o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0;
+ }
+ if (o.gauntletEliminated == null) o.gauntletEliminated = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ o.botWanderDx = wd[0];
+ o.botWanderDy = wd[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') {
+ o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900);
+ }
+ });
+ let botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length > wantBots) {
+ const drop = botIds.pop();
+ if (drop) others.delete(drop);
+ }
+ botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length < wantBots) {
+ /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */
+ let nextSlot = 0;
+ while (others.has(PREVIEW_BOT_PREFIX + nextSlot)) nextSlot++;
+ previewBotSeq = Math.max(previewBotSeq, nextSlot + 1);
+ const id = PREVIEW_BOT_PREFIX + nextSlot;
+ const joinIdx = countPlayHumans() + botIds.length;
+ let x;
+ let y;
+ if (mapData.gameType === 'jump_survive') {
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else {
+ const sp = pickSpawnForJoinPlay(mapData, joinIdx);
+ const jx = (Math.random() - 0.5) * 0.4;
+ const jy = (Math.random() - 0.5) * 0.4;
+ x = sp.x + 0.5 + jx;
+ y = sp.y + 0.5 + jy;
+ }
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ const botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay();
+ others.set(id, {
+ x, y, tx: x, ty: y,
+ direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)],
+ nickname: 'บอท',
+ characterId: pickPreviewBotCharacterId(botIds.length),
+ spawnJoinOrder: joinIdx,
+ playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id),
+ botTier,
+ gauntletJumpTicks: 0,
+ gauntletJumpVis: 0,
+ gauntletScore: crownStart ? 100 : 0,
+ gauntletEliminated: false,
+ quizCannotTrue: false,
+ quizCannotFalse: false,
+ quizCarryHeld: null,
+ botPath: [],
+ botAnswerWander: false,
+ botWanderDx: wd[0],
+ botWanderDy: wd[1],
+ botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: 0,
+ balloonBossScore: 0,
+ balloonBossBossDmg: 0,
+ balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5,
+ balloonBossEliminated: false,
+ botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
+ });
+ botIds.push(id);
+ }
+ let k = 0;
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
+ o.nickname = 'บอท ' + (++k) + ' ' + tag;
+ });
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => {
+ const o = others.get(bid);
+ if (o) o.characterId = pickPreviewBotCharacterId(botIdx);
+ });
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ const t0 = performance.now();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140);
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (mapData.gameType === 'stack') {
+ applyStackPreviewSpawnLayout();
+ rebuildStackPreviewTurnOrder();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(false);
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function pickRandomWalkableCellInAnswerGrid(grid, o) {
+ if (!grid || !mapData) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] !== 1) continue;
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */
+ function previewBotsPrepareAnswerRound() {
+ if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ const tier = o.botTier || 'avg';
+ let pCorrect = 0.52;
+ if (tier === 'sharp') pCorrect = 0.84;
+ else if (tier === 'weak') pCorrect = 0.22;
+ if (Math.random() < 0.14) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const wantsCorrect = Math.random() < pCorrect;
+ const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
+ const zoneGrid = targetTrue ? qt : qf;
+ const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
+ if (!dest) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (!path || path.length <= 1) {
+ o.botAnswerWander = true;
+ return;
+ }
+ o.botPath = path.slice(1);
+ });
+ }
+
+ /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
+ function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const tGrid = md.quizTrueArea || [];
+ const fGrid = md.quizFalseArea || [];
+ const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
+ const walkable = (x, y) => {
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ return row && row[x] !== 1;
+ };
+ const fx = Math.floor(Number(sx));
+ const fy = Math.floor(Number(sy));
+ if (!walkable(fx, fy)) {
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+ if (!inAnswer(fx, fy)) return { x: sx, y: sy };
+ const q = [[fx, fy]];
+ const seen = new Set([`${fx},${fy}`]);
+ const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
+ while (q.length) {
+ const [cx, cy] = q.shift();
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = cx + dirs[di][0];
+ const ny = cy + dirs[di][1];
+ const k = `${nx},${ny}`;
+ if (seen.has(k)) continue;
+ if (!walkable(nx, ny)) continue;
+ seen.add(k);
+ if (!inAnswer(nx, ny)) {
+ return { x: nx + 0.5, y: ny + 0.5 };
+ }
+ q.push([nx, ny]);
+ }
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+
+ function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
+ const tx = Math.floor(ox);
+ const ty = Math.floor(oy);
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ const inT = quizCellOnPlay(qt, tx, ty);
+ const inF = quizCellOnPlay(qf, tx, ty);
+ let choice = null;
+ if (inT && !inF) choice = true;
+ else if (inF && !inT) choice = false;
+ else if (inT && inF) choice = true;
+ const right = choice !== null && choice === correctTrue;
+ return { choice, right };
+ }
+
+ /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */
+ function quizPreviewJoinOrderForRespawn(actorId) {
+ if (actorId === myId) {
+ const jm = Number(me && me.spawnJoinOrder);
+ return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0;
+ }
+ const o = others.get(actorId);
+ if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) {
+ return Math.max(0, Math.floor(o.spawnJoinOrder));
+ }
+ if (!isPreviewBotId(actorId)) {
+ const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const idx = othersReal.indexOf(actorId);
+ return idx >= 0 ? 1 + idx : 1;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const bi = bots.indexOf(actorId);
+ if (bi < 0) return 0;
+ return countPlayHumans() + bi;
+ }
+
+ /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */
+ function resolvePreviewQuizRound() {
+ if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const results = [];
+ const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue);
+ let right = mine.right;
+ let choice = mine.choice;
+ if (mine.right) {
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ playQuizPlayerLocal.cannotTrue = true;
+ playQuizPlayerLocal.cannotFalse = true;
+ if (!warpMeIntoQuizGhostZonePlay()) {
+ const ordMe = quizPreviewJoinOrderForRespawn(myId);
+ const sp = pickSpawnForJoinPlay(mapData, ordMe);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ me.x = pos.x;
+ me.y = pos.y;
+ me.tx = me.x;
+ me.ty = me.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+ results.push({
+ id: myId,
+ right,
+ choice,
+ score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0,
+ });
+ if (playBotsEnabled()) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue);
+ o.botPath = [];
+ o.botAnswerWander = false;
+ if (br.right) {
+ playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ const ordB = quizPreviewJoinOrderForRespawn(id);
+ const sp = pickSpawnForJoinPlay(mapData, ordB);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ o.x = pos.x + (Math.random() - 0.5) * 0.35;
+ o.y = pos.y + (Math.random() - 0.5) * 0.35;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+ results.push({
+ id,
+ right: br.right,
+ choice: br.choice,
+ score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
+ });
+ });
+ }
+ const r = { results, scores: { ...playLiveQuizScores } };
+ results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) playQuizEverWrong[String(row.id)] = true;
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ }
+
+ function advancePreviewQuizIfDue() {
+ if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return;
+ if (previewQuizStep === 'done') return;
+ if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return;
+ const pool = previewQuizPool;
+ if (!pool || !pool.length) return;
+ if (previewQuizStep === 'read') {
+ applyPreviewAnswerPhase();
+ return;
+ }
+ if (previewQuizStep === 'answer') {
+ resolvePreviewQuizRound();
+ applyPreviewBetweenPhase();
+ return;
+ }
+ if (previewQuizStep === 'between') {
+ previewQuizQIndex += 1;
+ if (previewQuizQIndex >= pool.length) {
+ finishPreviewQuizSessionPlay();
+ return;
+ }
+ const q = pool[previewQuizQIndex];
+ if (q) applyPreviewReadPhase(q, pool.length);
+ }
+ }
+
+ async function loadPreviewQuizAndStart() {
+ resetPreviewBotsQuizState();
+ playQuizEverWrong = {};
+ let settings = null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) settings = await r.json();
+ } catch (e) { /* use map fallback */ }
+ let pool = [];
+ if (settings && Array.isArray(settings.questions) && settings.questions.length) {
+ pool = settings.questions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+ if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData);
+ const cap = clampPreviewQuizRoundQuestionCount(settings);
+ const shuffled = shufflePreviewQuizQuestionsPlay(pool);
+ previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length));
+ previewQuizQIndex = 0;
+ const dRead = 10000;
+ const dAns = 5000;
+ const dBet = 3500;
+ previewQuizTiming = {
+ readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000),
+ answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000),
+ betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000),
+ };
+ previewQuizCurrent = null;
+ if (!pool.length) {
+ playQuizPhaseLocal = 'read';
+ previewQuizStep = 'read';
+ playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ';
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ return;
+ }
+ const session = previewQuizPool;
+ const q = session[0];
+ if (q) applyPreviewReadPhase(q, session.length);
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ }
+
+ function startPlayQuizTimer() {
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(() => {
+ updatePlayQuizTimerDisplay();
+ advancePreviewQuizIfDue();
+ }, 200);
+ updatePlayQuizTimerDisplay();
+ }
+
+ function teardownPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ const mapQText = document.getElementById('quiz-map-question-text');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, mapQText);
+ }
+ playQuizPhaseLocal = null;
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ previewQuizPool = [];
+ previewQuizQIndex = 0;
+ previewQuizCurrent = null;
+ previewQuizStep = 'read';
+ playLiveQuizScores = {};
+ playQuizEverWrong = {};
+ quizTfScorePopups = [];
+ playPath = [];
+ hidePlayQuizHud();
+ quizCarryJoinSettingsSnap = null;
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ quizCarryMapPanelTheme = null;
+ quizMapPanelTheme = null;
+ quizCarryChoicePlaqueThemes = null;
+ resetQuizCarryPlayState();
+ resetQuizBattlePlayState();
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmTeardown) gcmTeardown.classList.add('is-hidden');
+ cancelEmbedPreviewLobbyReturnTimer();
+ const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn');
+ if (grabBtnTeardown) {
+ grabBtnTeardown.classList.add('is-hidden');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place');
+ grabBtnTeardown.style.pointerEvents = '';
+ const im = grabBtnTeardown.querySelector('img');
+ if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
+ }
+ const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn');
+ if (gchJumpTeardown) {
+ gchJumpTeardown.classList.add('is-hidden');
+ gchJumpTeardown.setAttribute('aria-hidden', 'true');
+ gchJumpTeardown.style.pointerEvents = '';
+ }
+ const stackDropTeardown = document.getElementById('stack-tower-drop-btn');
+ if (stackDropTeardown) {
+ stackDropTeardown.classList.add('is-hidden');
+ stackDropTeardown.setAttribute('aria-hidden', 'true');
+ stackDropTeardown.style.pointerEvents = '';
+ stackDropTeardown.classList.remove('stack-tower-drop-btn--active');
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function setupPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry())
+ || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live');
+ if (hideBottomQuizBar) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ const leg = document.getElementById('quiz-play-legend');
+ if (previewMode) {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…';
+ if (leg) leg.textContent = '';
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ';
+ if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/';
+ if (leg) leg.textContent = '';
+ } else {
+ loadPreviewQuizAndStart();
+ }
+ } else {
+ playQuizPhaseLocal = null;
+ playQuizText = 'รอคำถามจากโฮสต์…';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)';
+ if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ }
+ if (isQuiz() && !isQuizCarry()) {
+ (async () => {
+ try {
+ let s = {};
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ try {
+ s = JSON.parse(trimmed);
+ } catch (e) { /* ignore */ }
+ }
+ }
+ s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {});
+ setQuizMapPanelThemeFromApi(s);
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e2) { /* ignore */ }
+ } catch (e) { /* ignore */ }
+ })();
+ }
+ }
+
+ /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */
+ function applyQuizPhaseFromServer(p) {
+ if (!p || !mapData || !isQuiz()) return;
+ if (p.text) playQuizText = p.text;
+ playQuizPhaseLocal = p.phase;
+ playQuizPhaseEndsAt = p.endsAt || 0;
+ playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0);
+ playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ const qEl = document.getElementById('quiz-game-question');
+ if (phaseEl) {
+ phaseEl.textContent = p.phase === 'read'
+ ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''))
+ : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''));
+ }
+ if (qEl) qEl.textContent = playQuizText || '';
+ const ov = document.getElementById('quiz-game-overlay');
+ if (isQuizQuestionMissionHudActivePlay()) {
+ if (ov) ov.classList.add('is-hidden');
+ } else if (ov) {
+ ov.classList.remove('is-hidden');
+ }
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
+ updatePlayQuizTimerDisplay();
+ if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
+ if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {};
+ try {
+ syncPlayQuizMapPanel();
+ } catch (eQm) { /* ignore */ }
+ }
+
+ function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
+ function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
+ function isGauntletFaceRightMapMno9kb07() {
+ if (!isGauntlet()) return false;
+ /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */
+ return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID;
+ }
+ /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */
+ function isGauntletCrownHeistMapPlay() {
+ return isGauntletFaceRightMapMno9kb07();
+ }
+ function isMegaVirusMissionShellMapPlay() {
+ return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID);
+ }
+ /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */
+ function usesCrownLobbyShellPlay() {
+ return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay();
+ }
+ /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */
+ const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49;
+ /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */
+ const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1;
+ function isStackTowerEmbedZoomLockedPlay() {
+ return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay());
+ }
+ function applyPlayEmbedZoomForCurrentMapPlay() {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL;
+ }
+ }
+
+ /** smoothstep 0..1 */
+ function stackTowerPost50SmoothstepPlay(t) {
+ const x = Math.max(0, Math.min(1, t));
+ return x * x * (3 - 2 * x);
+ }
+
+ /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */
+ function getStackTowerPost50AnimEasePlay() {
+ if (!getStackTowerCameraFollowPlayConfig().enabled) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now();
+ const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS));
+ return stackTowerPost50SmoothstepPlay(u);
+ }
+
+ function getStackTowerPost50ZMulPlay() {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 1;
+ return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e;
+ }
+
+ function getStackTowerPost50BgScrollExtraPxPlay(ch) {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 0;
+ const chR = Math.max(1, Number(ch) || 720);
+ return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e;
+ }
+
+ /**
+ * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul).
+ * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom.
+ */
+ function computePlayCameraZDrawPlay() {
+ if (!canvas || !mapData) return zoom;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ let zDraw = zoom;
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */
+ zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90;
+ }
+ } else if (isQuizCarry()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizBattle()) {
+ zDraw *= quizBattleUserZoomMul;
+ }
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) {
+ zDraw *= playEmbedUserZoomMul;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL;
+ zDraw *= getStackTowerPost50ZMulPlay();
+ }
+ return zDraw;
+ }
+ let gauntletCrownHowtoVisible = false;
+ function isGauntletCrownPregameBlockingPlay() {
+ if (!usesCrownLobbyShellPlay()) return false;
+ if (gauntletCrownRunHeldRemote) return true;
+ if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
+ return false;
+ }
+
+ /**
+ * Last Light (crown heist): center camera on the whole party bbox instead of only `me`
+ * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible.
+ */
+ function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) {
+ if (!isGauntletCrownHeistMapPlay() || !mapData) return null;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ww = md.width || 20;
+ const hh = md.height || 15;
+ const mapWpx = ww * tileSizeRef;
+ const mapHpx = hh * tileSizeRef;
+ const halfW = canvasW / (2 * zDrawVal);
+ const halfH = canvasH / (2 * zDrawVal);
+ const ents = [];
+ if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) {
+ ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef });
+ }
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return;
+ ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef });
+ });
+ if (!ents.length) return null;
+ let minCx = Infinity;
+ let maxCx = -Infinity;
+ let sumCy = 0;
+ for (let i = 0; i < ents.length; i++) {
+ const e = ents[i];
+ minCx = Math.min(minCx, e.cx);
+ maxCx = Math.max(maxCx, e.cx);
+ sumCy += e.cy;
+ }
+ let px = (minCx + maxCx) * 0.5;
+ let py = sumCy / ents.length;
+ const minCamX = halfW;
+ const maxCamX = Math.max(minCamX, mapWpx - halfW);
+ const minCamY = halfH;
+ const maxCamY = Math.max(minCamY, mapHpx - halfH);
+ const viewW = halfW * 2;
+ const viewH = halfH * 2;
+ /*
+ * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก)
+ * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw)
+ */
+ if (viewW >= mapWpx) px = mapWpx * 0.5;
+ else px = Math.max(minCamX, Math.min(maxCamX, px));
+ if (viewH >= mapHpx) py = mapHpx * 0.5;
+ else py = Math.max(minCamY, Math.min(maxCamY, py));
+ return { px, py };
+ }
+
+ /** Preview bots only — full tail-off loss needs server support for real humans */
+ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40;
+ function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) {
+ if (!playBotsEnabled() || !isGauntletCrownHeistMapPlay() || !mapData) return;
+ if (gauntletCrownPregamePhase !== 'live') return;
+ const halfW = canvas.width / (2 * zDrawVal);
+ const worldMinX = camX - halfW;
+ const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const ts = mapData.tileSize || 32;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return;
+ const rightWorld = (Number(o.x) + cw) * ts;
+ if (rightWorld < gate) o.gauntletEliminated = true;
+ });
+ }
+
+ function isStack() { return mapData && mapData.gameType === 'stack'; }
+ function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
+
+ function currentPlayMapId() {
+ const q = (playSessionMapId || '').trim();
+ if (q) return q;
+ if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim();
+ return (playMapIdFromQuery || '').trim();
+ }
+
+ function isQuizQuestionMissionUiMapPlay() {
+ return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID;
+ }
+
+ function isQuizQuestionMissionPregameBlockingPlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionUiMapPlay() {
+ return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID;
+ }
+
+ function getStackTowerBlockWorldScalePlay() {
+ return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ }
+
+ function isStackTowerMissionPregameBlockingPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return false;
+ return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionHudActivePlay() {
+ return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ }
+
+ /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */
+ function isJumpSurviveMissionHudActivePlay() {
+ return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live';
+ }
+
+ /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */
+ function isSpaceShooterMissionHudActivePlay() {
+ return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live';
+ }
+
+ function stackTowerAssetUrl(file) {
+ return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, '');
+ }
+
+ function ensureStackTowerSlingImagePlay() {
+ if (!stackTowerSlingImg) {
+ stackTowerSlingImg = new Image();
+ stackTowerSlingImg.decoding = 'async';
+ stackTowerSlingImg.src = stackTowerAssetUrl('sling.png');
+ }
+ const im = stackTowerSlingImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerScorePopupImagesPlay() {
+ if (!stackTowerScorePlus10Img) {
+ stackTowerScorePlus10Img = new Image();
+ stackTowerScorePlus10Img.decoding = 'async';
+ stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png');
+ }
+ if (!stackTowerScorePlus20Img) {
+ stackTowerScorePlus20Img = new Image();
+ stackTowerScorePlus20Img.decoding = 'async';
+ stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png');
+ }
+ return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img };
+ }
+
+ function ensureStackTowerPerfectFxImagesPlay() {
+ if (!stackTowerScoreLightImg) {
+ stackTowerScoreLightImg = new Image();
+ stackTowerScoreLightImg.decoding = 'async';
+ stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png');
+ }
+ if (!stackTowerScoreX2Img) {
+ stackTowerScoreX2Img = new Image();
+ stackTowerScoreX2Img.decoding = 'async';
+ stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png');
+ }
+ return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img };
+ }
+
+ /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */
+ function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) {
+ if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return;
+ const iw = x2Img.naturalWidth;
+ const ih = x2Img.naturalHeight;
+ const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72));
+ const scale = targetH / ih;
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const cx = sx + drawW + drawW * 0.06;
+ const cy = sy + drawH * 0.5;
+ const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1;
+ const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI);
+ if (lightImg && lightImg.complete && lightImg.naturalWidth) {
+ const lp = 2.7;
+ const lw = dw * lp;
+ const lh = dh * lp;
+ const lnx = lightImg.naturalWidth;
+ const lny = lightImg.naturalHeight;
+ /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */
+ const iconCx = cx + dw * 0.02;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, pulse + 0.34);
+ ctx.globalCompositeOperation = 'screen';
+ try {
+ ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.globalCompositeOperation = 'source-over';
+ try {
+ ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+
+ function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) {
+ if (!m.scorePopups || !m.scorePopups.length) return;
+ const now = performance.now();
+ const dur = STACK_TOWER_SCORE_POPUP_MS;
+ const imgs = ensureStackTowerScorePopupImagesPlay();
+ m.scorePopups = m.scorePopups.filter((p) => p.until > now);
+ const hidPop = getStackTowerVisualHiddenLayerCountPlay();
+ for (let pi = 0; pi < m.scorePopups.length; pi++) {
+ const p = m.scorePopups[pi];
+ const li = Math.floor(Number(p.layerIndex)) || 0;
+ if (li < 0 || li >= m.layers.length) continue;
+ if (li < hidPop) continue;
+ const L = m.layers[li];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay();
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (li + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const t0 = p.until - dur;
+ const t = Math.max(0, Math.min(1, (now - t0) / dur));
+ const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t);
+ const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom;
+ const im = p.kind === '20' ? imgs.p20 : imgs.p10;
+ const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom));
+ let iw = targetW;
+ let ih = targetW * 0.38;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ ih = (targetW * im.naturalHeight) / im.naturalWidth;
+ }
+ const cx = sx + drawW / 2;
+ const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha));
+ if (im && im.complete && im.naturalWidth > 0) {
+ try {
+ ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e) { /* ignore */ }
+ } else {
+ ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd';
+ ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy);
+ }
+ ctx.restore();
+ }
+ }
+
+ /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */
+ function spawnStackMissDebrisPlay(sf) {
+ if (!sf || !stackMini) return;
+ const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3);
+ const cxW = sf.xLeft1 + sf.tw / 2;
+ const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0;
+ const dir = landOff >= 0 ? 1 : -1;
+ stackMissDebris.push({
+ cxW,
+ topY: sf.y1,
+ w: sf.tw,
+ h: lh,
+ vx: dir * (tileSize * (1.4 + Math.random() * 0.9)),
+ vy: -tileSize * (0.4 + Math.random() * 0.5),
+ rot: sf.tiltMax || 0,
+ vrot: dir * (2.0 + Math.random() * 1.8),
+ seat: sf.actorSeat,
+ heavy: !!sf.actorHeavy,
+ hue: (stackMini.layers.length * 47) % 360,
+ });
+ while (stackMissDebris.length > 6) stackMissDebris.shift();
+ _stackDebrisLastT = 0;
+ }
+ /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */
+ function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) {
+ if (!stackMissDebris.length) return;
+ const now = performance.now();
+ let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016;
+ _stackDebrisLastT = now;
+ if (dt > 0.05) dt = 0.05;
+ const g = tileSize * 34;
+ const chCv = canvas && canvas.height ? canvas.height : 720;
+ const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5;
+ for (let i = stackMissDebris.length - 1; i >= 0; i--) {
+ const d = stackMissDebris[i];
+ d.vy += g * dt;
+ d.topY += d.vy * dt;
+ d.cxW += d.vx * dt;
+ d.rot += d.vrot * dt;
+ if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; }
+ const sp = worldToScreen(d.cxW - d.w / 2, d.topY);
+ const drawW = d.w * zoom;
+ const drawH = d.h * zoom;
+ ctx.save();
+ ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2);
+ ctx.rotate(d.rot);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true });
+ ctx.restore();
+ }
+ }
+
+ function ensureStackTowerHeartMinusImgPlay() {
+ if (!stackTowerHeartMinusImg) {
+ stackTowerHeartMinusImg = new Image();
+ stackTowerHeartMinusImg.decoding = 'async';
+ stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png');
+ }
+ const im = stackTowerHeartMinusImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerLifeHudImagesPlay() {
+ if (!stackTowerLifeBarImg) {
+ stackTowerLifeBarImg = new Image();
+ stackTowerLifeBarImg.decoding = 'async';
+ stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png');
+ }
+ if (!stackTowerLifeHitImg) {
+ stackTowerLifeHitImg = new Image();
+ stackTowerLifeHitImg.decoding = 'async';
+ stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png');
+ }
+ if (!stackTowerLifeFullImg) {
+ stackTowerLifeFullImg = new Image();
+ stackTowerLifeFullImg.decoding = 'async';
+ stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png');
+ }
+ }
+
+ /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */
+ function ensureViolentCrimeLifeHudImagesPlay() {
+ if (!violentCrimeLifeBarImg) {
+ violentCrimeLifeBarImg = new Image();
+ violentCrimeLifeBarImg.decoding = 'async';
+ violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png');
+ }
+ if (!violentCrimeLifeHitImg) {
+ violentCrimeLifeHitImg = new Image();
+ violentCrimeLifeHitImg.decoding = 'async';
+ violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png');
+ }
+ if (!violentCrimeLifeFullImg) {
+ violentCrimeLifeFullImg = new Image();
+ violentCrimeLifeFullImg.decoding = 'async';
+ violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png');
+ }
+ return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg };
+ }
+
+ /**
+ * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG)
+ */
+ const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38;
+
+ /**
+ * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง
+ */
+ function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) {
+ const imgs = (opts && opts.images)
+ ? opts.images
+ : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg });
+ const bar = imgs.bar;
+ const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0)
+ ? imgs.full
+ : ensureStackTowerHeartMinusImgPlay();
+ const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0
+ ? imgs.hit
+ : null;
+ const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1));
+ const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0));
+
+ if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) {
+ ctx.save();
+ ctx.fillStyle = '#bb9af7';
+ ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ let heartStr = '';
+ for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ ';
+ ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5);
+ ctx.restore();
+ return Math.min(maxH, 28);
+ }
+
+ const bw0 = bar.naturalWidth;
+ const bh0 = bar.naturalHeight;
+ const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC));
+ const scale = Math.min(maxW / bw0, 1.45);
+ const bw = bw0 * scale;
+ const titleDh = titleSrcH * scale;
+ const gap = 3;
+ const bx = destX;
+ const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35);
+ const heartsTop = by0 + titleDh + gap;
+ const roomHearts = Math.max(10, destY + maxH - heartsTop - 2);
+ const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5)));
+
+ ctx.save();
+ try {
+ ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh);
+ } catch (e) { /* ignore */ }
+
+ const cy = heartsTop + heartsH * 0.5;
+ const bracketW = Math.min(14, Math.max(9, bw * 0.07));
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.88)';
+ ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'left';
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.45)';
+ ctx.shadowBlur = 4;
+ ctx.fillText('[', bx + 1, cy);
+ ctx.textAlign = 'right';
+ ctx.fillText(']', bx + bw - 1, cy);
+ ctx.shadowBlur = 0;
+
+ const ix = bx + bracketW + 4;
+ const iwInner = Math.max(24, bw - (bracketW + 4) * 2);
+ const slotW = iwInner / slots;
+ const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34);
+
+ for (let i = 0; i < slots; i++) {
+ const cx = ix + slotW * (i + 0.5);
+ const useFull = i < lives;
+ const im = useFull ? fullIm : hitIm;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ const nw = im.naturalWidth;
+ const nh = im.naturalHeight;
+ let ih = iconMax;
+ let iw = (ih * nw) / nh;
+ const capW = slotW * 0.82;
+ const capH = heartsH * 0.88;
+ if (iw > capW) {
+ iw = capW;
+ ih = (iw * nh) / nw;
+ }
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ if (iw < 6 && nh > 0) {
+ iw = 6;
+ ih = (iw * nh) / nw;
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ }
+ try {
+ ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e2) { /* ignore */ }
+ } else {
+ ctx.fillStyle = useFull ? '#9ece6a' : '#565f89';
+ ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(useFull ? '♥' : '♡', cx, cy);
+ }
+ }
+ ctx.restore();
+ const usedH = heartsTop + heartsH - by0 + 2;
+ return Math.min(maxH, Math.max(titleDh, usedH));
+ }
+
+ function triggerStackTowerHeartMinusFxPlay() {
+ if (!isStackTowerMissionHudActivePlay()) return;
+ stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS };
+ }
+
+ function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) {
+ if (!stackTowerHeartMinusFx) return;
+ const nt = performance.now();
+ if (nt >= stackTowerHeartMinusFx.until) {
+ stackTowerHeartMinusFx = null;
+ return;
+ }
+ const dur = STACK_TOWER_HEART_MINUS_FX_MS;
+ const t0 = stackTowerHeartMinusFx.until - dur;
+ const u = Math.max(0, Math.min(1, (nt - t0) / dur));
+ const alpha = 1 - u * u;
+ const worldCx = m.topCenterX * tileSize;
+ const stackTopY = floorY - nLay * layerWorldH;
+ const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5;
+ const [sx, sy] = worldToScreen(worldCx, worldCy);
+ const im = ensureStackTowerHeartMinusImgPlay();
+ const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom));
+ let dw = maxW;
+ let dh = maxW * 0.52;
+ if (im && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ dh = (maxW * im.naturalHeight) / im.naturalWidth;
+ }
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha));
+ if (im) {
+ ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('♥ −1', sx, sy);
+ }
+ ctx.restore();
+ }
+
+ /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */
+ function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) {
+ if (drawW <= 0 || drawH <= 0) return;
+ const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS));
+ const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI);
+ ctx.save();
+ ctx.globalCompositeOperation = 'source-over';
+ /* เติมเหลืองทับบล็อกให้เด่น */
+ ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32);
+ ctx.fillStyle = 'rgba(255, 224, 60, 0.55)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */
+ const pad = Math.max(3, Math.min(12, drawW * 0.05));
+ const sw = Math.max(3, Math.min(9, drawW * 0.055));
+ ctx.shadowColor = 'rgba(255, 196, 24, 0.98)';
+ ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16));
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 0;
+ ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38);
+ ctx.strokeStyle = 'rgba(255, 238, 110, 1)';
+ ctx.lineWidth = sw;
+ ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw);
+ /* เส้นในขาวอมเหลือง เพิ่มความคม */
+ ctx.shadowBlur = 0;
+ ctx.shadowColor = 'transparent';
+ ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4);
+ ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)';
+ ctx.lineWidth = Math.max(1.5, sw * 0.42);
+ ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2);
+ ctx.restore();
+ }
+
+ /**
+ * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว
+ */
+ function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) {
+ const dx = sx1 - sx0;
+ const dy = sy1 - sy0;
+ const len = Math.hypot(dx, dy);
+ if (len < 1.5) return;
+ const sling = ensureStackTowerSlingImagePlay();
+ if (sling) {
+ const nw = sling.naturalWidth;
+ const nh = sling.naturalHeight;
+ const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2));
+ const ang = Math.atan2(dy, dx);
+ ctx.save();
+ ctx.translate(sx0, sy0);
+ ctx.rotate(ang - Math.PI / 2);
+ ctx.globalAlpha = 0.96;
+ try {
+ ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len);
+ } catch (e) { /* ignore */ }
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ } else {
+ ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)';
+ ctx.lineWidth = Math.max(2.2, zoom * 1.4);
+ ctx.beginPath();
+ ctx.moveTo(sx0, sy0);
+ ctx.lineTo(sx1, sy1);
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ }
+ }
+
+ const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower';
+
+ function setGauntletCrownHowtoStackTowerArtMaskPlay(on) {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!hov) return;
+ hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on);
+ }
+
+ function hideStackTowerHowtoRulesDom() {
+ /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */
+ }
+
+ function teardownStackTowerMissionUiPlay() {
+ stackTowerMissionPhase = null;
+ stackTowerMissionDeferredPhase = null;
+ stackTowerSessionStartAt = 0;
+ stackTowerMissionEndedOnce = false;
+ stackTowerPost50AnimStartMs = null;
+ hideStackTowerHowtoRulesDom();
+ setGauntletCrownHowtoStackTowerArtMaskPlay(false);
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function hideStackTowerResultFlashDomOnlyPlay() {
+ if (stackTowerResultFlashTimer) {
+ clearTimeout(stackTowerResultFlashTimer);
+ stackTowerResultFlashTimer = null;
+ }
+ const ov = document.getElementById('stack-tower-result-flash');
+ const img = document.getElementById('stack-tower-result-flash-img');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ if (img) {
+ img.onerror = null;
+ img.removeAttribute('src');
+ }
+ }
+
+ /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */
+ function beginStackTowerResultFlashThenGcm(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outc = String(mission.stackTowerOutcome || 'time_up');
+ let file = 'result-timeup.png';
+ if (outc === 'decrypt_complete') file = 'result-complete.png';
+ else if (outc === 'misses_exhausted') file = 'result-gameover.png';
+ const url = stackTowerAssetUrl(file);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = url;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ /** Jumper mnptfts2: เกรด F = ภาพ gameover · อื่น = complete (ก่อนแผงสรุปผล) */
+ function jumpSurviveMissionOutcomeImageFile(mission) {
+ const g = mission && String(mission.grade || '').toUpperCase();
+ if (g === 'F') return 'result-gameover.png';
+ return 'result-complete.png';
+ }
+
+ /**
+ * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome;
+ * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM
+ */
+ function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'jumper') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = jumperAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash(outcomeFile, finishToGcm);
+ });
+ } else {
+ showOneFlash(outcomeFile, finishToGcm);
+ }
+ }
+
+ /**
+ * Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM;
+ * ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM
+ */
+ function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'violent_crime') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = violentCrimeAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash('result-gameover.png', finishToGcm);
+ });
+ } else {
+ showOneFlash('result-gameover.png', finishToGcm);
+ }
+ }
+
+ /**
+ * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM
+ * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow)
+ * — ชนะ = end-victory-2.png ใน MegaVirus
+ */
+ function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) {
+ if (!mission || mission.uiSkin !== 'mega_virus') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const lose = endReason === 'all_dead' || endReason === 'time';
+ const flashSrc = lose ? stackTowerAssetUrl('result-gameover.png') : megaVirusAssetUrl('end-victory-2.png');
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = flashSrc;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ function stackTowerMissionTimeLimitSecPlay() {
+ const t = Number(playStackTowerMissionTimeSec);
+ if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t)));
+ return 90;
+ }
+
+ function stackTowerProgressBlocksPlay() {
+ const n = Number(playStackTowerProgressBlocks);
+ if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n)));
+ return 50;
+ }
+
+ function stackTowerDecryptPctPlay() {
+ if (!stackMini) return 0;
+ if (isStackTowerMissionUiMapPlay()) {
+ const p = Number(stackMini.progressPct);
+ const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0));
+ if (x >= 99.999) return 100;
+ return Math.floor(x);
+ }
+ const teamScore = stackMini.score || 0;
+ return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
+ }
+
+ function stackTowerMissionElapsedSecPlay() {
+ if (stackTowerSessionStartAt <= 0) return 0;
+ return (performance.now() - stackTowerSessionStartAt) / 1000;
+ }
+
+ function applyStackTowerMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = stackTowerAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = stackTowerAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ ensureStackTowerSlingImagePlay();
+ }
+
+ function showStackTowerMissionHowtoOverlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ stackTowerMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ setGauntletCrownHowtoStackTowerArtMaskPlay(true);
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ }
+ }
+
+ /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */
+ function updateStackTowerMissionHowtoHud() {
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */
+ function updateJumpSurviveMissionHowtoHud() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */
+ function updateSpaceShooterMissionHowtoHud() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginStackTowerMissionCountdownThenRun() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+ gauntletCrownHowtoVisible = false;
+ hideStackTowerHowtoRulesDom();
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'live';
+ stackTowerMissionEndedOnce = false;
+ stackTowerSessionStartAt = performance.now();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ stackTowerMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function stackTowerMissionBuildPayload(endOpts) {
+ const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete';
+ const rows = [];
+ const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ function pushEnt(id, nickname, characterId, score) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0);
+ }
+ stackPreviewTurnOrder.forEach(function (entry) {
+ if (!entry || entry.kind !== 'bot' || !entry.botId) return;
+ const o = others.get(entry.botId);
+ pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0);
+ });
+ } else {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ pushEnt(id, o.nickname, o.characterId, teamPts);
+ });
+ }
+ const seen = new Set();
+ const uniq = [];
+ rows.forEach(function (r) {
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ uniq.push(r);
+ });
+ uniq.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = uniq.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && uniq.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: uniq.length,
+ participantCount: uniq.length,
+ stackTowerOutcome: outcome,
+ };
+ }
+
+ function stackTowerMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.stackBotScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.stackBotScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ stackTowerOutcome: mission.stackTowerOutcome,
+ };
+ }
+
+ function stackTowerMissionMaybeEndPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionPhase !== 'live') return;
+ if (stackTowerMissionEndedOnce) return;
+ const pct = stackTowerDecryptPctPlay();
+ const elapsed = stackTowerMissionElapsedSecPlay();
+ const timeLim = stackTowerMissionTimeLimitSecPlay();
+ const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0;
+ const timeOut = elapsed >= timeLim;
+ if (pct < 100 && !timeOut && !missesOut) return;
+ stackTowerMissionEndedOnce = true;
+ stackTowerMissionPhase = 'ended';
+ applyStackTowerMissionPanelImages();
+ let outcome = 'time_up';
+ if (pct >= 100) outcome = 'decrypt_complete';
+ else if (missesOut) outcome = 'misses_exhausted';
+ beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome }));
+ }
+
+ function isJumpSurviveMissionUiMapPlay() {
+ return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID;
+ }
+
+ function isJumpSurviveMissionPregameBlockingPlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return false;
+ return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended';
+ }
+
+ function jumperAssetUrl(file) {
+ return BASE + '/img/Jumper/' + file;
+ }
+
+ function hideConflictingOverlaysForJumpSurviveMission() {
+ hideConflictingOverlaysForGauntletCrown();
+ }
+
+ function jumpSurviveTimeLimitSecForMission() {
+ const m = Number(mapData && mapData.jumpSurviveTimeSec);
+ if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
+ const j = Number(playJumpSurviveMissionTimeSec);
+ if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j);
+ return 60;
+ }
+
+ function jumpSurviveRemainingSecMission() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null;
+ const lim = jumpSurviveTimeLimitSecForMission();
+ if (!(lim > 0)) return null;
+ const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function applyJumpSurviveJumperPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = jumperAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = jumperAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showJumpSurviveMissionHowtoOverlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ jumpSurviveMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ }
+ }
+
+ function beginJumpSurviveMissionCountdownThenRun() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'live';
+ jumpSurviveGameEnded = false;
+ jumpSurviveEliminated = false;
+ applyJumpSurvivePreviewSpawnLayout(false);
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ jumperMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */
+ function jumpSurviveGradeFromSurvivorCount(survivors) {
+ const s = Math.max(0, Math.floor(Number(survivors) || 0));
+ if (s >= 6) return 'A';
+ if (s === 5) return 'B';
+ if (s === 4) return 'C';
+ if (s === 3) return 'D';
+ if (s === 2) return 'E';
+ return 'F';
+ }
+
+ function jumpSurviveRollRewardCardForGrade(grade) {
+ if (grade === 'F') return null;
+ const r = Math.random();
+ if (grade === 'A' || grade === 'B') {
+ if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C' || grade === 'D') {
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'E') {
+ if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ function jumpSurviveMissionTotalParticipants() {
+ let n = myId != null ? 1 : 0;
+ if (others && typeof others.size === 'number') n += others.size;
+ return n;
+ }
+
+ function jumpSurviveMissionCountAlive() {
+ let n = 0;
+ if (myId != null && !jumpSurviveEliminated) n++;
+ if (others && typeof others.forEach === 'function') {
+ others.forEach(function (o) {
+ if (o && !o.jumpSurviveEliminated) n++;
+ });
+ }
+ return n;
+ }
+
+ /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */
+ function jumpSurviveMissionMaybeEarlyFinish() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const alive = jumpSurviveMissionCountAlive();
+ if (alive === 0) {
+ endJumpSurviveMissionRound('all_dead');
+ }
+ }
+
+ function jumpSurviveBuildMissionPayload() {
+ const rows = [];
+ const pushEnt = function (id, nickname, characterId, eliminated) {
+ const alive = !eliminated;
+ const baseScore = alive ? 100 : 0;
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: baseScore,
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: baseScore,
+ });
+ };
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < rows.length; si++) {
+ if (rows[si] && !rows[si].eliminated) survivorCount++;
+ }
+ const participantCount = rows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = missionEnsureHumanInTopRanked(rows, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const rewardCard = jumpSurviveRollRewardCardForGrade(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function jumpSurviveMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.jumpSurviveEliminated ? 0 : 100,
+ eliminated: !!o.jumpSurviveEliminated,
+ rankBonus: 0,
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < baseRows.length; si++) {
+ if (baseRows[si] && !baseRows[si].eliminated) survivorCount++;
+ }
+ const participantCount = baseRows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: jumpSurviveRollRewardCardForGrade(grade),
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function endJumpSurviveMissionRound(endKind) {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return;
+ jumpSurviveGameEnded = true;
+ jumpSurviveMissionPhase = 'ended';
+ applyJumpSurviveJumperPanelImages();
+ const mission = jumpSurviveBuildMissionPayload();
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+ function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
+
+ function isSpaceShooterMissionUiMapPlay() {
+ return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID;
+ }
+
+ function isSpaceShooterMissionPregameBlockingPlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return false;
+ return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended';
+ }
+
+ function violentCrimeAssetUrl(file) {
+ return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, '');
+ }
+
+ function megaVirusAssetUrl(file) {
+ return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, '');
+ }
+
+ function applyGauntletCrownHeistMissionPanelImages() {
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isGauntletCrownHeistMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ howtoOv.classList.toggle('gch-mg2-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = missionMockHudAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg && isGauntletCrownHeistMapPlay()) {
+ resBg.src = missionMockHudAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applyMegaVirusMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = megaVirusAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = megaVirusAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applySpaceShooterMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = violentCrimeAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = violentCrimeAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showSpaceShooterMissionHowtoOverlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ spaceShooterMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ spaceShooterResetMissionShipStateForAllPlay();
+ }
+
+ function beginSpaceShooterMissionCountdownThenRun() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'live';
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ if (myId != null) me.spaceShooterScore = 0;
+ others.forEach(function (o) {
+ if (o) o.spaceShooterScore = 0;
+ });
+ spaceShooterResetMissionShipStateForAllPlay();
+ if (mapData) applySpaceShooterSpawnLayoutPlay();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ spaceShooterMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function spaceShooterBuildMissionPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score, eliminated) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: rows.length,
+ };
+ }
+
+ function spaceShooterMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ eliminated: !!o.spaceShooterEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function balloonBossMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ eliminated: !!o.balloonBossEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach(function (o) {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ const endR = mission && mission.balloonBossEndReason;
+ if (endR === 'victory' || progress >= 1) grade = 'A';
+ else if (endR === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length,
+ participantCount: baseRows.length,
+ balloonBossEndReason: mission.balloonBossEndReason,
+ };
+ }
+
+ function balloonBossBuildMissionPayloadPlay(reason) {
+ const rows = [];
+ if (myId != null) {
+ rows.push({
+ id: myId,
+ nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'),
+ characterId: me.characterId ?? null,
+ baseScore: Math.max(0, me.balloonBossScore | 0),
+ eliminated: !!me.balloonBossEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ rows.push({
+ id: id,
+ nickname: (o && o.nickname) ? String(o.nickname).trim() : id,
+ characterId: o && o.characterId != null ? o.characterId : null,
+ baseScore: Math.max(0, o.balloonBossScore | 0),
+ eliminated: !!(o && o.balloonBossEliminated),
+ });
+ });
+ rows.sort((a, b) => b.baseScore - a.baseScore
+ || String(a.nickname).localeCompare(String(b.nickname), 'th')
+ || String(a.id).localeCompare(String(b.id)));
+ const top = rows.slice(0, 5);
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map((r) => r.baseScore);
+ const totalSum = totalParts.reduce((s, n) => s + n, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ if (reason === 'victory' || progress >= 1) grade = 'A';
+ else if (reason === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: rows.filter((r) => !r.eliminated).length,
+ participantCount: rows.length,
+ balloonBossEndReason: reason,
+ };
+ }
+
+ function endSpaceShooterMissionRound(endKind) {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return;
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ spaceShooterGameEnded = true;
+ spaceShooterMissionPhase = 'ended';
+ applySpaceShooterMissionPanelImages();
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterPopups = [];
+ const mission = spaceShooterBuildMissionPayload();
+ mission.spaceShooterEndKind = kind;
+ beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+
+ function questionMissionAssetUrl(file) {
+ return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, '');
+ }
+
+ /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */
+ function questionMissionHudAssetUrl(file) {
+ if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file);
+ return questionMissionAssetUrl(file);
+ }
+
+ /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */
+ function getMissionMockHudSkinPlay() {
+ if (isQuizQuestionMissionUiMapPlay()) return 'question_mission';
+ if (isStackTowerMissionUiMapPlay()) return 'stack_tower';
+ if (isJumpSurviveMissionUiMapPlay()) return 'jumper';
+ if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime';
+ if (isMegaVirusMissionShellMapPlay()) return 'mega_virus';
+ if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry';
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return '';
+ }
+
+ function isMissionMockHudPlay() {
+ return !!getMissionMockHudSkinPlay();
+ }
+
+ function missionMockHudAssetUrl(file) {
+ const skin = getMissionMockHudSkinPlay();
+ const f = String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(f);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(f);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(f);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(f);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f;
+ return questionMissionAssetUrl(f);
+ }
+
+ function applyMissionMockHudCssVarsPlay(root) {
+ if (!root) return;
+ if (!isMissionMockHudPlay()) {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.removeProperty('--mmh-score-label');
+ root.style.removeProperty('--mmh-score-avatar');
+ root.style.removeProperty('--mmh-mic-on');
+ root.style.removeProperty('--mmh-btn-ready');
+ root.style.removeProperty('--mmh-btn-start');
+ delete root.dataset.missionMockSkin;
+ return;
+ }
+ const skin = getMissionMockHudSkinPlay();
+ root.dataset.missionMockSkin = skin;
+ if (skin === 'gauntlet_crown') {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')");
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (crownHead && crownImg) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ crownImg.classList.add('mg2-score-label');
+ }
+ } else {
+ root.style.removeProperty('--mmh-score-label');
+ root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "')");
+ }
+ root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')");
+ root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')");
+ root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')");
+ root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')");
+ const micImg = document.getElementById('play-cyber-mic-img');
+ if (micImg) {
+ micImg.classList.remove('is-hidden');
+ micImg.setAttribute('aria-hidden', 'false');
+ const micSrc = missionMockHudAssetUrl('btn-mic_on.png');
+ if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc);
+ }
+ const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay');
+ if (frameOverlay) {
+ const frameSrc = missionMockHudAssetUrl('Avartar.png');
+ if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc);
+ }
+ }
+
+ function isQuizQuestionMissionHudActivePlay() {
+ return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ }
+
+ /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
+ function getQuizQuestionMissionMapCenterWorldPxPlay() {
+ if (!mapData || !isQuiz()) return null;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ const ts = tileSize;
+ const mwPx = w * ts;
+ const mhPx = h * ts;
+ return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx };
+ }
+
+ function applyQuizQuestionMissionPanelImages() {
+ /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isQuizQuestionMissionUiMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = questionMissionAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = questionMissionAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showQuizQuestionMissionHowtoOverlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ quizQuestionMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ /** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ }
+ }
+
+ function beginQuizQuestionMissionCountdownThenRun() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'live';
+ playEmbedUserZoomMul = 1;
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) {
+ if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden');
+ else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden');
+ else qov.classList.remove('is-hidden');
+ }
+ const dp = quizQuestionMissionDeferredPhase;
+ quizQuestionMissionDeferredPhase = null;
+ if (dp) applyQuizPhaseFromServer(dp);
+ if (previewMode && isQuiz()) loadPreviewQuizAndStart();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ quizQuestionMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function quizQuestionMissionBuildPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score) {
+ const sid = String(id);
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && rows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: rows.length,
+ participantCount: rows.length,
+ };
+ }
+
+ function quizQuestionMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: !!r.hadQuizWrong,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
+ function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
+
+ /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */
+ function quizCarryEmbedMissionFlowActive() {
+ if (!isQuizCarry()) return false;
+ if (previewMode && editorEmbedReturn) return true;
+ return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay);
+ }
+ /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */
+ function getQuizCarryMapCameraWorldCenterPxPlay() {
+ if (!mapData || mapData.gameType !== 'quiz_carry') return null;
+ const ww = mapData.width || 20, hh = mapData.height || 15;
+ const ts = tileSize;
+ return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 };
+ }
+ function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
+
+ function quizCarryNeonColorForChoice(choiceIndex) {
+ const i = Math.max(0, choiceIndex | 0);
+ const h = (i * 47 + 100) % 360;
+ return 'hsl(' + h + ', 72%, 62%)';
+ }
+
+ function quizCarryMinimapOptionFillCss(ov) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1);
+ if (th.borderMode === 'fixed' && th.fixedBorder) {
+ const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim());
+ if (m) {
+ const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1;
+ const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32;
+ const t = Math.round(aa * 1000) / 1000;
+ return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')';
+ }
+ }
+ const i = Math.max(0, ov - 1);
+ const h = (i * 47 + 100) % 360;
+ return 'hsla(' + h + ', 58%, 52%, 0.32)';
+ }
+
+ function normalizeQuizCarryQuestionFromAny(q) {
+ if (!q) return null;
+ let text = String(q.text || q.question || q.prompt || q.title || '').trim();
+ if (Array.isArray(q.choices) && q.choices.length >= 2) {
+ const choices = q.choices.map((c) => String(c));
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
+ if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
+ let chSlot = Number(q.countdownHighlightSlot);
+ if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null;
+ else chSlot = Math.floor(chSlot);
+ let choiceImageUrls = null;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) choiceImageUrls = urls;
+ }
+ return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls };
+ }
+ if (!text) return null;
+ const t = !!q.answerTrue;
+ return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null };
+ }
+
+ function buildQuizCarryPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ const out = [];
+ for (let i = 0; i < md.quizQuestions.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
+ if (n) out.push(n);
+ }
+ return out;
+ }
+
+ function getQuizCarryHubTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ const grid = md.quizCarryHubArea;
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (row[xx] === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
+ function getQuizCarryOptionClusterBounds(md, slot1toN) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let n = 0, sumX = 0, sumY = 0;
+ for (let yy = 0; yy < g.length; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === slot1toN) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ sumX += xx + 0.5;
+ sumY += yy + 0.5;
+ n++;
+ }
+ }
+ }
+ if (!n || minX === Infinity) return null;
+ return {
+ minX, minY, maxX, maxY,
+ cx: sumX / n,
+ cy: sumY / n,
+ };
+ }
+
+ function canvasWordWrapLines(ctx, text, maxWidth) {
+ const raw = String(text || '').trim();
+ if (!raw) return [];
+ const words = raw.split(/\s+/);
+ const lines = [];
+ let cur = words[0];
+ for (let i = 1; i < words.length; i++) {
+ const w = words[i];
+ const test = cur + ' ' + w;
+ if (ctx.measureText(test).width <= maxWidth) cur = test;
+ else {
+ lines.push(cur);
+ cur = w;
+ }
+ }
+ lines.push(cur);
+ return lines;
+ }
+
+ function quizCarryOptionHeldByAnyone(optionIdx) {
+ if (optionIdx == null || optionIdx < 0) return false;
+ if (me.quizCarryHeld === optionIdx) return true;
+ for (const o of others.values()) {
+ if (o && o.quizCarryHeld === optionIdx) return true;
+ }
+ return false;
+ }
+
+ /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */
+ function spriteOverlapsQuizCarryHubArea(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !sp) return false;
+ const hub = md.quizCarryHubArea;
+ if (!hub || !hub.length) return false;
+ const mw = md.width || 20, mh = md.height || 15;
+ const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = hub[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ if (row[xx] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
+ function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1) return null;
+ let p = preferredIdx | 0;
+ if (p < 0 || p >= n) p = 0;
+ if (!quizCarryOptionHeldByAnyone(p)) return p;
+ const avail = [];
+ for (let i = 0; i < n; i++) {
+ if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
+ }
+ if (!avail.length) return null;
+ return avail[Math.floor(Math.random() * avail.length)];
+ }
+
+ /**
+ * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl
+ * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
+ * @param plaqueExtra optional { imageUrl }
+ */
+ function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex);
+ const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex);
+ const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)';
+ const textCol = th.textColor || '#f8f9ff';
+ const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
+ const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
+ const elImg = plaqueExtra && plaqueExtra.imageElement;
+ const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
+ const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
+ const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
+ const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
+ const simplePreviewPlaque = previewMode && editorEmbedReturn;
+ function pathBoard() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
+ else ctx.rect(signX, signY, w, h);
+ }
+ if (!simplePreviewPlaque) {
+ for (let g = 4; g >= 1; g--) {
+ ctx.strokeStyle = neon;
+ ctx.globalAlpha = 0.07 + g * 0.06;
+ ctx.lineWidth = baseLw + g * 3.2;
+ pathBoard();
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = fillCol;
+ pathBoard();
+ ctx.fill();
+ if (drawPlaqueImg) {
+ ctx.save();
+ pathBoard();
+ ctx.clip();
+ const padImg = Math.max(2, padY * 0.65);
+ const ix = signX + padImg;
+ const iy = signY + padImg;
+ const iw = Math.max(0, w - padImg * 2);
+ const ih = Math.max(0, h - padImg * 2);
+ if (iw > 0 && ih > 0) {
+ const nw = drawPlaqueImg.naturalWidth;
+ const nh = drawPlaqueImg.naturalHeight;
+ const ir = nw / nh;
+ let dw;
+ let dh;
+ if (iw / ih > ir) {
+ dh = ih;
+ dw = dh * ir;
+ } else {
+ dw = iw;
+ dh = dw / ir;
+ }
+ const dx = ix + (iw - dw) / 2;
+ const dy = iy + (ih - dh) / 2;
+ ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
+ }
+ ctx.restore();
+ }
+ ctx.strokeStyle = neon;
+ if (simplePreviewPlaque) {
+ ctx.lineWidth = Math.max(1, baseLw * 0.85);
+ ctx.shadowBlur = 0;
+ pathBoard();
+ ctx.stroke();
+ } else {
+ ctx.lineWidth = baseLw;
+ ctx.shadowColor = neon;
+ ctx.shadowBlur = 14;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 8;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ const attachX = signX + w / 2;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = textCol;
+ const lh = lineH || 12;
+ if (!simplePreviewPlaque && lines && lines.length) {
+ ctx.shadowColor = 'rgba(0,0,0,0.55)';
+ ctx.shadowBlur = 3;
+ }
+ if (lines && lines.length) {
+ const n = lines.length;
+ const textBlockH = n * lh;
+ const y0 = signY + Math.max(padY, (h - textBlockH) / 2);
+ const firstLineCenterY = y0 + lh / 2;
+ for (let li = 0; li < n; li++) {
+ ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh);
+ }
+ }
+ ctx.shadowBlur = 0;
+ if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)';
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.shadowBlur = 0;
+ ctx.beginPath();
+ ctx.moveTo(tether.x0, tether.y0);
+ ctx.lineTo(tether.x1, tether.y1);
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based
+ */
+ function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null;
+ const q = quizCarryCurrent;
+ const choices = q.choices;
+ if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null;
+ const i = idx0Based;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) return null;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ let signX;
+ let signY;
+ let w;
+ let h;
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ signX = sx - minW / 2;
+ signY = sy - minH / 2;
+ w = minW;
+ h = minH;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ const line = choiceText;
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ let maxW = Math.max(56, tileSpanX * ts - 14);
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(maxLineW + padX * 2);
+ h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+
+ /**
+ * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor)
+ * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด
+ */
+ function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) {
+ if (!isQuizCarry() || !mapData) return;
+ if (mapData.carryEmbedCountdownHighlight === false) return;
+ const optIdx = getQuizCarryLocalGrabbableOptionIndex();
+ if (optIdx == null) return;
+ const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx);
+ if (!pr || pr.w < 4 || pr.h < 4) return;
+ const rx = pr.signX;
+ const ry = pr.signY;
+ const rw = pr.w;
+ const rh = pr.h;
+ const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14));
+ const colRaw = mapData.carryEmbedCountdownHighlightColor;
+ const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a';
+ const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180);
+ ctx.save();
+ function pathRr() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr);
+ else ctx.rect(rx, ry, rw, rh);
+ }
+ pathRr();
+ ctx.fillStyle = strokeBase;
+ ctx.globalAlpha = 0.12 * pulse;
+ ctx.fill();
+ for (let g = 6; g >= 1; g--) {
+ ctx.strokeStyle = strokeBase;
+ ctx.globalAlpha = (0.07 + g * 0.055) * pulse;
+ ctx.lineWidth = 2.5 + g * 4;
+ pathRr();
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 0.98;
+ ctx.strokeStyle = strokeBase;
+ ctx.lineWidth = 4;
+ ctx.shadowColor = strokeBase;
+ ctx.shadowBlur = 22 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 12 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ }
+
+ function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (!quizCarryOptionsPickableNow()) return;
+ const choices = quizCarryCurrent.choices;
+ if (!choices || !choices.length) return;
+ const maxSlots = choices.length;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ ctx.save();
+ for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
+ if (quizCarryOptionHeldByAnyone(i)) continue;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) continue;
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const lineH = fontPx * 1.2;
+ const padY = 8;
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
+ continue;
+ }
+ const line = choiceText;
+ let fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ let maxW = Math.max(96, tileSpanX * ts * 1.5 - 14);
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ /* คำตอบไทยมักเป็น "คำเดียวยาว" (ไม่มีช่องว่าง → wrap ไม่ได้) → ย่อฟอนต์ให้พอดีกว้าง + ปล่อยป้ายกว้างได้ กันโดนตัด "…" */
+ while (fontPx > 8 && ctx.measureText(line).width > maxW) {
+ fontPx -= 1;
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ }
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(maxLineW + padX * 2);
+ let h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
+ continue;
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ let h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
+ }
+ ctx.restore();
+ }
+
+ function quizCarryHubCellPlay(md, tx, ty) {
+ const g = md && md.quizCarryHubArea;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryInteractiveCellPlay(md, tx, ty) {
+ const g = md && md.interactive;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryMapHasAnswerInteractive(md) {
+ if (!md || !md.interactive || !md.interactive.length) return false;
+ const h = md.height || 15, w = md.width || 20;
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
+ function quizCarryOptionIndexAtPlay(md, tx, ty) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g[ty]) return null;
+ const v = g[ty][tx];
+ if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
+ const idx = v - 1;
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1 || idx >= n) return null;
+ return idx;
+ }
+
+ /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
+ function quizCarryFootprintOverlapsHub(px, py) {
+ if (!mapData || !isQuizCarry()) return false;
+ const md = mapData;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
+ function quizCarryFootprintAdjacentToHub(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryHubCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtHub(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
+ }
+
+ function quizCarryFootprintOverlapsInteractive(md, px, py) {
+ if (!md) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ function quizCarryFootprintAdjacentToInteractive(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
+ }
+
+ /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
+ function quizCarryCanSubmitAnswerAt(md, px, py) {
+ if (!md) return false;
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ return quizCarryCanSubmitAtInteractiveZone(md, px, py);
+ }
+ return quizCarryCanSubmitAtHub(md, px, py);
+ }
+
+ /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
+ function formatQuizCarryQuestionHud(q) {
+ if (!q) return '';
+ return String(q.text || q.question || q.prompt || q.title || '').trim();
+ }
+
+ function quizCarryRestoreEmbedPhaseLabel() {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl || !mapData) return;
+ phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
+ ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
+ : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
+ }
+
+ function isQuizCarryEmbedCountdownBlockingMovement() {
+ if (!quizCarryEmbedMissionFlowActive()) return false;
+ const now = Date.now();
+ return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now);
+ }
+
+ function isQuizCarryEmbedLobbyBlockingMovement() {
+ return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive);
+ }
+
+ function quizCarryPregameHumanIds() {
+ const ids = [];
+ if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId));
+ others.forEach((_, id) => {
+ if (!isPreviewBotId(id)) ids.push(String(id));
+ });
+ return ids;
+ }
+
+ function quizCarryPregameBotCount() {
+ let n = 0;
+ others.forEach((_, id) => {
+ if (isPreviewBotId(id)) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (quizCarryLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameTotalPlayers() {
+ return quizCarryPregameHumanIds().length + quizCarryPregameBotCount();
+ }
+
+ function isMePlayHost() {
+ if (myId == null) return false;
+ if (playHostId == null) return true;
+ return String(playHostId) === String(myId);
+ }
+
+ function hideQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ function showQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ updateQuizCarryPregameHud();
+ }
+
+ function quizCarrySyncGuestReadyIfNeeded() {
+ if (!quizCarryPregameActive || myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (quizCarryLobbyReadyMap[sid]) return;
+ quizCarryLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true });
+ }
+
+ function updateQuizCarryPregameHud() {
+ if (!quizCarryPregameActive) return;
+ const st = document.getElementById('quiz-carry-pregame-status');
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
+ const num = quizCarryPregameReadyNumerator();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
+ if (st) {
+ /* ตาม design 01-howto / 01-howto-host: โฮสต์เห็น "กำลังรอ..ผู้เล่นกด Ready" / ผู้เล่นเห็น "Ready Status" */
+ st.textContent = isMePlayHost()
+ ? (humansReady ? 'ผู้เล่นพร้อมแล้ว — กด START : ' + num + '/' + tot
+ : 'กำลังรอ..ผู้เล่นกด Ready : ' + num + '/' + tot)
+ : 'Ready Status : ' + num + '/' + tot;
+ }
+ if (primaryImg) {
+ primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
+ primaryImg.alt = humansReady ? 'START' : 'READY';
+ }
+ if (primary) {
+ primary.classList.toggle('is-start-phase', humansReady);
+ primary.classList.toggle('is-read-only', !isMePlayHost());
+ primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady);
+ primary.disabled = !isMePlayHost();
+ primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false'));
+ primary.title = isMePlayHost()
+ ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+ }
+
+ function beginQuizCarryEmbedPregame() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ quizCarryPregameActive = true;
+ quizCarryLobbyReadyMap = {};
+ quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; });
+ showQuizCarryPregameOverlay();
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request');
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+
+ function endQuizCarryEmbedPregameAndStart() {
+ if (!quizCarryPregameActive) return;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPickNextQuestion();
+ const nowEmb = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowEmb) && !(quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ }
+
+ function quizCarryOptionsPickableNow() {
+ if (!isQuizCarry() || !quizCarryCurrent) return true;
+ /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return false;
+ if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
+ return Date.now() >= quizCarryOptionRevealAt;
+ }
+
+ function quizCarryApplyPhaseTimersForCurrentQuestion() {
+ if (!quizCarryCurrent) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ return;
+ }
+ const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ if (readMs <= 0) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ } else {
+ quizCarryOptionRevealAt = t0 + readMs;
+ quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
+ }
+ }
+
+ /** embed: หลัง 3–2–1 ก่อนหยิบ — เปิดป้ายตัวเลือกทันที + จับเวลาตอบ carryAnswerMs */
+ function quizCarryApplyEmbedAnswerWindowAfterPreOption321() {
+ if (!quizCarryCurrent) return;
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ }
+
+ function updateQuizCarryCarryPhaseHud() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !quizCarryCurrent) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) return;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl) return;
+ if (quizCarryAnswerTimeupAwaitNext && quizCarryAnswerRoundTimeupAdvanceAt > 0) {
+ const sec = Math.max(0, Math.ceil((quizCarryAnswerRoundTimeupAdvanceAt - Date.now()) / 1000));
+ phaseEl.textContent = 'หมดเวลาตอบ — ไปข้อถัดไปใน ' + sec + ' วิ…';
+ return;
+ }
+ const now = Date.now();
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > 0 && now < quizCarryEmbedPreOptionCountdownEndAt) {
+ const sec = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - now) / 1000));
+ phaseEl.textContent = 'เตรียมหยิบคำตอบ — นับ 3·2·1 (' + sec + ' วิ)';
+ return;
+ }
+ if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
+ const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
+ phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
+ return;
+ }
+ if (quizCarryAnswerCloseAt > now) {
+ const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
+ phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
+ return;
+ }
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+
+ function tickQuizCarryRoundTimers() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (quizCarryEmbedCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPendingQuestion) return;
+ if (quizCarryAnswerTimeupAwaitNext) return;
+ if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
+ if (Date.now() < quizCarryAnswerCloseAt) return;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryOptionRevealAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (isDetectiveMinigamePlay()) {
+ quizCarryAfterRoundResolved();
+ return;
+ }
+ quizCarryAnswerTimeupAwaitNext = true;
+ showQuizCarryAnswerRoundTimeupOverlay();
+ updateQuizCarryCarryPhaseHud();
+ }
+
+ function resetQuizCarryEmbedCountdownOverlayInners() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = 'คำถาม · Question';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ /** นับ 3–2–1: ซ่อนคำถามและหัวข้อ — เหลือแค่รูปเลขจาก /Game/img/QUESTION */
+ function showQuizCarryEmbedCountdownDigitsOnlyChrome() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = '';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ function hideQuizCarryEmbedCountdownOverlay() {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ resetQuizCarryEmbedCountdownOverlayInners();
+ }
+
+ function tickQuizCarryEmbedCountdown() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ const now = Date.now();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+
+ if (quizCarryCurrent && quizCarryEmbedPreOptionCountdownEndAt > 0) {
+ if (!numEl) return;
+ if (now >= quizCarryEmbedPreOptionCountdownEndAt) {
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ quizCarryApplyEmbedAnswerWindowAfterPreOption321();
+ quizCarryRestoreEmbedPhaseLabel();
+ syncQuizCarryEmbedQuestionStrip();
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsedPre = now - quizCarryEmbedPreOptionCountdownStartAt;
+ const nPre = 3 - Math.min(2, Math.floor(elapsedPre / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, nPre)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+
+ if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
+ if (now >= quizCarryEmbedCountdownEndAt) {
+ const pq = quizCarryEmbedPendingQuestion;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ if (!pq) return;
+ quizCarryCurrent = pq;
+ preloadQuizCarryChoiceImages(pq);
+ playQuizText = formatQuizCarryQuestionHud(pq);
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ quizCarryRestoreEmbedPhaseLabel();
+ others.forEach((o) => {
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ syncQuizCarryEmbedQuestionStrip();
+ quizCarryEmbedPreOptionCountdownStartAt = Date.now();
+ quizCarryEmbedPreOptionCountdownEndAt = quizCarryEmbedPreOptionCountdownStartAt + 3000;
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsed = now - quizCarryEmbedCountdownStartAt;
+ const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, n)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+
+ function quizCarryPickNextQuestion() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!quizCarryPool.length) {
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
+ return;
+ }
+ let q;
+ let guard = 0;
+ do {
+ q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
+ guard++;
+ } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
+ if (quizCarryEmbedMissionFlowActive()) {
+ quizCarryEmbedPendingQuestion = q;
+ preloadQuizCarryChoiceImages(q);
+ quizCarryEmbedCountdownStartAt = Date.now();
+ quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryCurrent = null;
+ playQuizText = '';
+ clearQuizMapQuestionCarryFeedback();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = '…';
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
+ tickQuizCarryEmbedCountdown();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+ quizCarryCurrent = q;
+ preloadQuizCarryChoiceImages(q);
+ playQuizText = formatQuizCarryQuestionHud(q);
+ clearQuizMapQuestionCarryFeedback();
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ });
+ quizCarryApplyPhaseTimersForCurrentQuestion();
+ }
+
+ /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */
+ function quizCarryAfterRoundResolved() {
+ if (!isQuizCarry()) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) {
+ finishDetectiveMinigameAndReturnLobby();
+ return;
+ }
+ if (!quizCarryEmbedMissionFlowActive() && !previewMode) {
+ quizCarryPickNextQuestion();
+ return;
+ }
+ quizCarryRoundsCompleted++;
+ const lim = quizCarrySessionLength;
+ if (lim > 0 && quizCarryRoundsCompleted >= lim) {
+ showQuizCarrySessionCompleteOverlay();
+ return;
+ }
+ quizCarryPickNextQuestion();
+ }
+
+ function playQuizCarryAvatarUrlForMission(characterId) {
+ if (!characterId) return '';
+ return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
+ }
+
+ /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */
+ function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) {
+ if (!imgEl || !characterId) return;
+ let attempts = 0;
+ const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId);
+ const tick = () => {
+ attempts++;
+ const nowT = Date.now();
+ const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down');
+ if (!rawImg) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) {
+ if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts);
+ setTimeout(tick, 90);
+ return;
+ }
+ if (!rawImg.naturalWidth) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ const tint = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local'));
+ const isMePeer = (peerId != null && myId != null && String(peerId) === String(myId));
+ const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer);
+ if (out && out.tagName === 'CANVAS' && out.width > 0) {
+ try {
+ imgEl.src = out.toDataURL('image/png');
+ return;
+ } catch (e) { /* ต่อไปลอง walk หรือ URL */ }
+ }
+ if (out && out.src && out !== rawImg) {
+ imgEl.src = out.src;
+ return;
+ }
+ if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) {
+ setTimeout(tick, 120);
+ return;
+ }
+ imgEl.src = rawImg.src || fallbackUrl;
+ };
+ tick();
+ }
+
+ /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */
+ function quizCarryGradeFromTeamAverage(avgRounded) {
+ if (avgRounded >= 80) return 'A';
+ if (avgRounded >= 60) return 'B';
+ return 'C';
+ }
+
+ function quizCarryRollCardRewardForGrade(grade) {
+ const u = Math.random() * 100;
+ if (grade === 'A') {
+ if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C') {
+ if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ return null;
+ }
+
+ function quizCarryBuildMissionRankList() {
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ characterId: me.characterId || getPlayCharacterId(),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ characterId: o.characterId || null,
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ return ranks.slice(0, 5);
+ }
+
+ /** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */
+ function quizCarryBuildGcmMissionPayload(opts) {
+ opts = opts || {};
+ const ranks = quizCarryBuildMissionRankList();
+ const ranked = ranks.map(function (row, idx) {
+ const pos = idx + 1;
+ const sc = Math.max(0, Number(row.score) || 0);
+ return {
+ id: row.id, nickname: row.nickname, characterId: row.characterId,
+ baseScore: sc, eliminated: false,
+ rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0, finalScore: sc,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.min(100, Math.max(0, Math.round(totalSum / n)));
+ const grade = opts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(averageScore);
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked, totalSum: totalSum, averageScore: averageScore,
+ grade: grade, rewardCard: rewardCard, totalParts: totalParts,
+ uiSkin: 'quiz_carry', survivorCount: opts.forceGradeF ? 0 : ranked.length,
+ participantCount: ranked.length,
+ };
+ }
+
+ /** ตั้งรูปพื้นหลังหน้าจบของ overlay กลางเป็นชุด quiz-carry (cut) */
+ function applyQuizCarryGcmMissionBg() {
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = BASE + '/img/quiz-carry/popup-result.png';
+ resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; };
+ }
+ }
+
+ function cancelEmbedPreviewLobbyReturnTimer() {
+ if (embedPreviewLobbyReturnTimer != null) {
+ clearTimeout(embedPreviewLobbyReturnTimer);
+ embedPreviewLobbyReturnTimer = null;
+ }
+ }
+
+ function cancelQuizCarryResultEndAfterTimeup() {
+ if (quizCarryResultEndTimer != null) {
+ clearTimeout(quizCarryResultEndTimer);
+ quizCarryResultEndTimer = null;
+ }
+ cancelEmbedPreviewLobbyReturnTimer();
+ }
+
+ function cancelQuizCarrySessionCompleteResultToSummary() {
+ if (quizCarrySessionCompleteResultToSummaryT != null) {
+ clearTimeout(quizCarrySessionCompleteResultToSummaryT);
+ quizCarrySessionCompleteResultToSummaryT = null;
+ }
+ }
+
+ function cancelQuizCarryAnswerRoundTimeupAuto() {
+ if (quizCarryAnswerRoundTimeupAutoT != null) {
+ clearTimeout(quizCarryAnswerRoundTimeupAutoT);
+ quizCarryAnswerRoundTimeupAutoT = null;
+ }
+ quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ }
+
+ function quizCarrySetAnswerRoundTimeupVisualActive(on) {
+ try {
+ if (on) document.documentElement.classList.add('quiz-carry-answer-timeup-active');
+ else document.documentElement.classList.remove('quiz-carry-answer-timeup-active');
+ } catch (e) { /* ignore */ }
+ }
+
+ /** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */
+ function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ if (!(previewMode && editorEmbedReturn)) return;
+ embedPreviewLobbyReturnTimer = setTimeout(function () {
+ embedPreviewLobbyReturnTimer = null;
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }, 5000);
+ }
+
+ /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */
+ function quizCarryAnyPlayerHasCorrectAnswer() {
+ if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false;
+ const keys = Object.keys(playLiveQuizScores);
+ for (let i = 0; i < keys.length; i++) {
+ if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true;
+ }
+ return false;
+ }
+
+ function hideQuizCarryResultEndLayer() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ function showQuizCarryResultEndLayer(useComplete) {
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ const img = document.getElementById('quiz-carry-result-end-img');
+ if (!el || !img) return;
+ const fname = useComplete ? 'result-complete.png' : 'result-gameover.png';
+ if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl(fname) + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ }
+
+ /** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */
+ function scheduleEmbedDeskResultAfterDelay(okResolver) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryResultEndLayer();
+ const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; };
+ quizCarryResultEndTimer = setTimeout(function () {
+ quizCarryResultEndTimer = null;
+ let anyOk = false;
+ try {
+ anyOk = !!resolve();
+ } catch (e) {
+ anyOk = false;
+ }
+ hideQuizCarryTimeupOnDeskLayer();
+ showQuizCarryResultEndLayer(anyOk);
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ }, 5000);
+ }
+
+ function hideQuizCarryTimeupOnDeskLayer() {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) {
+ quizCarryAnswerTimeupAwaitNext = false;
+ return;
+ }
+ el.classList.remove('qc-timeup-desk--interactive');
+ quizCarryAnswerTimeupAwaitNext = false;
+ const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner');
+ if (anchor) {
+ anchor.style.left = '';
+ anchor.style.top = '';
+ anchor.style.width = '';
+ anchor.style.height = '';
+ }
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ /** หมดเวลาตอบ (carryAnswerMs) — โชว์ TIME'S UP บนโซนคำถาม ~3s แล้วไปข้อถัดไป (ไม่ใช่ flow 5s → result ของ embed จบภารกิจ) */
+ function showQuizCarryAnswerRoundTimeupOverlay() {
+ if (!isQuizCarry()) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ quizCarrySetAnswerRoundTimeupVisualActive(true);
+ quizCarryAnswerRoundTimeupAdvanceAt = Date.now() + 3000;
+ el.classList.add('qc-timeup-desk--interactive');
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ quizCarryAnswerRoundTimeupAutoT = setTimeout(function () {
+ quizCarryAnswerRoundTimeupAutoT = null;
+ dismissQuizCarryAnswerRoundTimeupAndContinue();
+ }, 3000);
+ }
+
+ function dismissQuizCarryAnswerRoundTimeupAndContinue() {
+ if (!quizCarryAnswerTimeupAwaitNext) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ quizCarryAfterRoundResolved();
+ }
+
+ /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน
+ * @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */
+ function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ // Last Light (Gauntlet Crown): skip TIME'S UP desk graphic; keep embed 5s → result flow.
+ if (isGauntletCrownHeistMapPlay()) {
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(
+ typeof embedResultOkFn === 'function' ? embedResultOkFn : quizCarryAnyPlayerHasCorrectAnswer
+ );
+ }
+ return;
+ }
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer);
+ }
+ }
+
+ function showQuizCarryMissionSummaryOverlay(opts) {
+ opts = opts || {};
+ const forceF = !!opts.forceGradeF;
+ const overlay = document.getElementById('quiz-carry-mission-overlay');
+ if (!overlay) return;
+ hideQuizCarryTimeupOnDeskLayer();
+ const ranks = quizCarryBuildMissionRankList();
+ const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0));
+ const total = parts.reduce((a, b) => a + b, 0);
+ const n = ranks.length;
+ const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0;
+ const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade);
+ const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade);
+
+ const rowEl = document.getElementById('qc-mission-rank-row');
+ const totalEl = document.getElementById('qc-mission-total-line');
+ const gradeEl = document.getElementById('qc-mission-grade');
+ const cardTextEl = document.getElementById('qc-mission-card-text');
+ const checkEl = document.getElementById('qc-mission-check');
+ const successTxt = document.getElementById('qc-mission-success-txt');
+ const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]'];
+ if (rowEl) {
+ rowEl.innerHTML = '';
+ ranks.forEach((r, idx) => {
+ const rank = idx + 1;
+ const cell = document.createElement('div');
+ cell.className = 'qc-mission-rank-cell';
+ const tag = document.createElement('div');
+ tag.className = 'qc-mission-rank-tag';
+ tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']');
+ const frame = document.createElement('div');
+ frame.className = 'qc-mission-rank-frame';
+ const img = document.createElement('img');
+ img.className = 'qc-mission-rank-avatar';
+ img.alt = '';
+ applyQuizCarryRankAvatarTint(img, r.characterId, r.id);
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = 'data:image/svg+xml,' + encodeURIComponent('? ');
+ };
+ frame.appendChild(img);
+ const nick = document.createElement('div');
+ nick.className = 'qc-mission-rank-nick';
+ nick.textContent = r.nickname;
+ nick.title = r.nickname;
+ const sc = document.createElement('div');
+ sc.className = 'qc-mission-rank-score';
+ sc.textContent = String(r.score);
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ }
+ if (totalEl) {
+ totalEl.textContent = '';
+ if (!n) {
+ totalEl.textContent = '—';
+ } else {
+ const lab = document.createElement('span');
+ lab.textContent = 'คะแนนรวม ';
+ const nums = document.createElement('span');
+ nums.className = 'qc-mission-total-nums';
+ nums.textContent = '(' + parts.join('+') + ') = ' + total;
+ const tail = document.createElement('span');
+ tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100';
+ totalEl.appendChild(lab);
+ totalEl.appendChild(nums);
+ totalEl.appendChild(tail);
+ }
+ }
+ if (gradeEl) {
+ gradeEl.textContent = grade;
+ gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase();
+ }
+ const okMission = grade !== 'F';
+ if (checkEl) checkEl.style.display = okMission ? '' : 'none';
+ if (successTxt) {
+ successTxt.style.display = okMission ? '' : 'none';
+ if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ';
+ }
+ if (cardTextEl) {
+ cardTextEl.textContent = '';
+ if (grade === 'F') {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = 'ไม่ได้รับการ์ด';
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = 'เกรด F';
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ } else if (reward) {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = reward.th;
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = reward.en;
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ }
+ /* การ์ดพิเศษจากชุดคำถาม (ได้มาจากการตอบคำถามพิเศษในฉาก) — แสดงเสมอถ้าได้รับ */
+ if (specialQuizAwardedCard) renderSpecialCardInBonus(cardTextEl, specialQuizAwardedCard);
+ }
+ overlay.classList.remove('is-hidden');
+ const btn = document.getElementById('btn-quiz-carry-mission-done');
+ if (btn) {
+ btn.onclick = function () {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ overlay.classList.add('is-hidden');
+ if (isDetectiveMinigamePlay()) {
+ finishDetectiveMinigameAndReturnLobby();
+ } else {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ };
+ }
+ }
+
+ function showQuizCarrySessionCompleteOverlay() {
+ if (quizCarrySessionEnded) return;
+ quizCarrySessionEnded = true;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ playQuizText = 'ครบชุดคำถามแล้ว';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
+ const missionOv = document.getElementById('quiz-carry-mission-overlay');
+ if (missionOv && quizCarryUseMissionSummaryOverlay) {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ hideQuizCarryResultEndLayer();
+ const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) };
+ const splashRanks = quizCarryBuildMissionRankList();
+ const splashParts = splashRanks.map((r) => Math.max(0, Number(r.score) || 0));
+ const splashTotal = splashParts.reduce((a, b) => a + b, 0);
+ const splashN = splashRanks.length;
+ const splashAvg = splashN ? Math.min(100, Math.max(0, Math.round(splashTotal / splashN))) : 0;
+ const splashGrade = summaryOpts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(splashAvg);
+ const useCompleteSplash = splashGrade !== 'F';
+ showQuizCarryResultEndLayer(useCompleteSplash);
+ quizCarrySessionCompleteResultToSummaryT = setTimeout(function () {
+ quizCarrySessionCompleteResultToSummaryT = null;
+ hideQuizCarryResultEndLayer();
+ /* ใช้หน้าจบ overlay กลาง (เหมือนเกมอื่น) skin quiz_carry แทน overlay เฉพาะเดิม */
+ showGauntletCrownMissionOverlay(quizCarryBuildGcmMissionPayload(summaryOpts));
+ }, QUIZ_CARRY_SESSION_END_SPLASH_MS);
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'เกมจบ · Game over';
+ msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ /** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */
+ function showQuizCarryToast(msg, ok) {
+ if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
+ window.__quizCarryToastT = null;
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.className = 'play-quiz-feedback';
+ el.textContent = '';
+ }
+
+ /**
+ * หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
+ * @returns {boolean} ทำ action สำเร็จหรือไม่
+ */
+ function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
+ opts = opts || {};
+ if (quizCarryPregameActive) return false;
+ if (quizCarrySessionEnded) return false;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(ox, oy);
+ const ent = actorId === myId ? me : others.get(actorId);
+ if (!ent) return false;
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
+ const correct = quizCarryCurrent.correctIndex;
+ let held = ent.quizCarryHeld;
+ if (held != null && canSubmitAnswer) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ ent.quizCarryHeld = null;
+ const ok = held === correct;
+ if (ok) {
+ playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
+ const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
+ const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
+ const showMapFb = !opts.silent && actorId === myId;
+ if (showMapFb) showQuizMapQuestionCarryFeedback(true);
+ if (!opts.silent) {
+ const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
+ showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
+ }
+ me.quizCarryHeld = null;
+ others.forEach((o) => { if (o) o.quizCarryHeld = null; });
+ const afterOk = () => { quizCarryAfterRoundResolved(); };
+ if (showMapFb) {
+ window.setTimeout(afterOk, 560);
+ } else {
+ afterOk();
+ }
+ } else {
+ if (!opts.silent && actorId === myId) {
+ showQuizMapQuestionCarryFeedback(false);
+ }
+ if (!opts.silent) {
+ showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
+ }
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return true;
+ }
+ if (held == null && pickupIdx != null) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ if (quizCarryOptionHeldByAnyone(pickupIdx)) {
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
+ }
+ return false;
+ }
+ ent.quizCarryHeld = pickupIdx;
+ const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
+ if (!opts.silent) {
+ showQuizCarryToast('หยิบ: ' + label, true);
+ }
+ return true;
+ }
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ const t = Date.now();
+ if (t - (me.quizCarryHintT || 0) > 2200) {
+ me.quizCarryHintT = t;
+ if (held != null && !canSubmitAnswer) {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
+ : 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
+ } else {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
+ : 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
+ }
+ }
+ }
+ return false;
+ }
+
+ function quizCarryGrabCoreGatesOk() {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ if (quizCarryPregameActive || quizCarrySessionEnded) return false;
+ if (myId == null) return false;
+ if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
+ if (!quizCarryOptionsPickableNow()) return false;
+ return true;
+ }
+
+ /** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */
+ function getQuizCarryLocalGrabbableOptionIndex() {
+ if (!quizCarryGrabCoreGatesOk()) return null;
+ if (me.quizCarryHeld != null) return null;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(me.x, me.y);
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0];
+ const ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null;
+ return pickupIdx;
+ }
+
+ /** ยืนบนช่องตัวเลือกและหยิบได้ */
+ function quizCarryGrabPickupAvailable() {
+ return getQuizCarryLocalGrabbableOptionIndex() != null;
+ }
+
+ /** ถือป้ายแล้วยืนโซนส่งได้ */
+ function quizCarryGrabSubmitAvailable() {
+ if (!quizCarryGrabCoreGatesOk()) return false;
+ if (me.quizCarryHeld == null) return false;
+ return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
+ }
+
+ /** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
+ function quizCarryGrabInteractionAvailable() {
+ return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
+ }
+
+ function syncQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ if (!isQuizCarry() || !mapData) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('quiz-carry-grab-btn--active');
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.setAttribute('aria-disabled', 'true');
+ return;
+ }
+ const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
+ const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
+ const img = btn.querySelector('img');
+ btn.classList.remove('is-hidden');
+ const active = quizCarryGrabInteractionAvailable();
+ btn.classList.toggle('quiz-carry-grab-btn--active', active);
+ btn.setAttribute('aria-disabled', active ? 'false' : 'true');
+ btn.style.pointerEvents = active ? '' : 'none';
+ if (img) {
+ if (active && quizCarryGrabSubmitAvailable()) {
+ img.src = placeSrc;
+ btn.classList.add('quiz-carry-grab-btn--place');
+ btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
+ btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
+ } else {
+ img.src = grabSrc;
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.title = 'หยิบตัวเลือก — เหมือนกด F';
+ btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
+ }
+ }
+ }
+
+ /** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */
+ function tryRequestGauntletJumpPlay() {
+ if (!mapData || !isGauntlet() || isChatFocused()) return false;
+ if (isGauntletCrownPregameBlockingPlay()) return false;
+ if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false;
+ const now = Date.now();
+ if (now - lastGauntletJumpKey < 200) return false;
+ lastGauntletJumpKey = now;
+ socket.emit('gauntlet-jump');
+ return true;
+ }
+
+ function syncGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ /* MG5 jump_survive (jumper): โชว์ปุ่ม JUMP มุมขวาล่าง (design .mg5-jump 1648/818/231) — ให้ touch กระโดดได้ (เดิม keyboard-only) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) {
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ btn.classList.add('mg2-jump');
+ const jimg = btn.querySelector('img');
+ if (jimg && jimg.src.indexOf('/Jumper/') < 0) jimg.src = jumperAssetUrl('btn-jump.png');
+ const hudJ = document.getElementById('play-cyber-hud');
+ if (hudJ && hudJ.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hudJ);
+ syncMg2MockJumpButtonBoundsPlay();
+ return;
+ }
+ const pregameUi = gauntletCrownPregamePhase === 'howto' || gauntletCrownPregamePhase === 'countdown';
+ const liveUi = gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const show = !!(mapData && isGauntletCrownHeistMapPlay() && !me.gauntletEliminated && (pregameUi || liveUi));
+ if (!show) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('mg2-jump');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ const mg2Jump = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ btn.classList.toggle('mg2-jump', !!mg2Jump);
+ if (mg2Jump) {
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hud);
+ syncMg2MockJumpButtonBoundsPlay();
+ } else {
+ btn.classList.remove('mg2-jump');
+ }
+ }
+
+ function syncStackTowerDropButtonPlay() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ if (!mapData || !isStack() || !stackMini) {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ const ok = stackHumanDropGateOkPlay();
+ btn.classList.toggle('stack-tower-drop-btn--active', ok);
+ btn.style.pointerEvents = ok ? '' : 'none';
+ }
+
+ /** MG3 (mnn93hpi): ผู้เล่นตาปัจจุบัน (active node) สำหรับ host portrait ใน DOM HUD */
+ function mg3CurrentSeatRowPlay() {
+ const seat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ let entry = null;
+ if (order && order.length) entry = order.find((e) => e.seat === seat) || null;
+ if (!entry || entry.kind === 'human') {
+ return {
+ id: myId,
+ characterId: me.characterId || getPlayCharacterId(),
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ id: entry.botId,
+ characterId: o ? o.characterId : null,
+ isMe: false,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ /**
+ * MG3 Stack Tower live HUD → DOM overlay (#mg3-tower-hud) — canvas object-fit:cover ครอป HUD ขอบจอ
+ * จึงย้าย life bar (ขวาบน) + progress (ซ้ายล่าง) + code terminal + host frame มาเป็น DOM สเกลตาม stage
+ */
+ function syncStackTowerMissionDomHudPlay() {
+ const root = document.getElementById('mg3-tower-hud');
+ if (!root) return;
+ const on = !!(mapData && isStack() && stackMini && isStackTowerMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+
+ if (!root.dataset.mg3Assets) {
+ const setSrc = (sel, file) => { const el = root.querySelector(sel); if (el) el.src = stackTowerAssetUrl(file); };
+ setSrc('.mg3-life-bar', 'life-bar.png');
+ setSrc('.mg3-host-frame', 'Avartar.png');
+ setSrc('.mg3-terminal-bg', 'code-bg.png');
+ setSrc('.mg3-progress-label', 'progress-txt.png');
+ setSrc('.mg3-progress-track', 'progress-bg.png');
+ setSrc('.mg3-progress-fill', 'progress.png');
+ root.dataset.mg3Assets = '1';
+ }
+
+ /* วาง design plane 1920×1080 → scale แบบ contain + จัดกึ่งกลางใน stage (HUD เห็นครบ ไม่โดน object-fit:cover ครอป) */
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.mg3Box !== key) {
+ root.dataset.mg3Box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+
+ const slots = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ const lives = Math.max(0, Math.min(slots, Math.floor(Number(
+ stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : (stackMini.lives != null ? stackMini.lives : 3),
+ ) || 0)));
+ const heartsEl = document.getElementById('mg3-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + lives;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = stackTowerAssetUrl(i < lives ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+
+ const pct = Math.max(0, Math.min(100, Math.round(Number(stackTowerDecryptPctPlay()) || 0)));
+ if (root.dataset.mg3Pct !== String(pct)) {
+ root.dataset.mg3Pct = String(pct);
+ /* fill เป็น px ในระนาบ design (track ภายในจาก left:30 กว้างสุด ~358px) */
+ const fillEl = document.getElementById('mg3-progress-fill');
+ if (fillEl) fillEl.style.width = Math.round(358 * pct / 100) + 'px';
+ const pctEl = document.getElementById('mg3-progress-pct');
+ if (pctEl) pctEl.textContent = '[ ' + pct + '% ]';
+ }
+
+ const hostAv = document.getElementById('mg3-host-av');
+ if (hostAv) {
+ const row = mg3CurrentSeatRowPlay();
+ const hk = (row.id != null ? row.id : 'me') + '|' + (row.characterId || '');
+ if (hostAv.dataset.hk !== hk) {
+ hostAv.dataset.hk = hk;
+ setCyberHudScoreAvatarImg(hostAv, row);
+ }
+ }
+
+ const logEl = document.getElementById('mg3-terminal-log');
+ if (logEl) {
+ const towerLiveNow = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (towerLiveNow && (!mg3TermLog || !mg3TermLog.length)) mg3TermBoot();
+ else if (!towerLiveNow && mg3TermLog && mg3TermLog.length) mg3TermReset();
+ let lines;
+ if (mg3TermLog && mg3TermLog.length) lines = mg3TermLog.slice(-9);
+ else if (stackPreviewHudLog && stackPreviewHudLog.length) lines = stackPreviewHudLog.slice(-7);
+ else lines = ['>>> DECRYPT UPLINK READY', '>>> SPACE / CLICK = DROP (ตาคุณ)'];
+ const txt = lines.join('\n');
+ if (logEl.textContent !== txt) logEl.textContent = txt;
+ }
+ }
+
+ /**
+ * MG6 Space Shooter life → overlay #mg6-life-hud (ระนาบ design 1920×1080 scale ตาม stage แบบเดียวกับ MG3)
+ * life-bar.png + หัวใจ 3 ดวง (ViolentCrime) ตำแหน่ง/ขนาดเท่า MG3 — ไม่พึ่ง flex ของ cyber HUD (กันบีบ/ล้นขอบ)
+ */
+ function syncSpaceShooterLifeDomHudPlay() {
+ const root = document.getElementById('mg6-life-hud');
+ if (!root) return;
+ const on = !!(mapData && isSpaceShooter() && isSpaceShooterMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ if (!root.dataset.assets) {
+ const bar = root.querySelector('.mg6-life-bar');
+ if (bar) bar.src = violentCrimeAssetUrl('life-bar.png');
+ root.dataset.assets = '1';
+ }
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const slots = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS;
+ const hits = Math.max(0, Math.min(slots, Number(me.spaceShooterHits) || 0));
+ const rem = slots - hits;
+ const heartsEl = document.getElementById('mg6-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + rem;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = violentCrimeAssetUrl(i < rem ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ im.decoding = 'async';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+ }
+
+ /** Quiz Battle (qbroom*) เลขห้องจาก mapId → tag/หมวด */
+ function quizBattleRoomNoPlay() {
+ const m = String(currentPlayMapId() || '').match(/qbroom(\d+)/i);
+ return m ? Math.max(1, Math.min(10, parseInt(m[1], 10))) : 0;
+ }
+
+ /**
+ * Quiz Battle in-game HUD → overlay #qb-game-hud (design plane 1920×1080 scale ตาม stage)
+ * header หมวด (tag-NN), PLAYERS x/50, SCORE (ตอบถูก), host avatar. EXIT/MY RESULT/RANKING wire แยก
+ */
+ function syncQuizBattleHudPlay() {
+ const root = document.getElementById('qb-game-hud');
+ if (!root) return;
+ const on = !!(mapData && isQuizBattle());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ /* ซ่อนกล่อง dev instruction ('QUIZ BATTLE · โหลดคำถามแล้ว...') มุมซ้ายล่าง — ไม่มีใน design */
+ const qgOv = document.getElementById('quiz-game-overlay');
+ if (qgOv && !qgOv.classList.contains('is-hidden')) qgOv.classList.add('is-hidden');
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const roomNo = quizBattleRoomNoPlay();
+ const tag = document.getElementById('qb-hud-tag');
+ if (tag && roomNo > 0) {
+ const want = BASE + '/img/qb-ui/tag-' + (roomNo < 10 ? '0' + roomNo : String(roomNo)) + '.png';
+ if (tag.dataset.src !== want) { tag.dataset.src = want; tag.src = want; }
+ }
+ const pv = document.getElementById('qb-hud-players-val');
+ if (pv) {
+ const n = countPlayHumans();
+ const max = (mapData && mapData.maxPlayers) || 50;
+ const txt = n + '/' + max;
+ if (pv.textContent !== txt) pv.textContent = txt;
+ }
+ const sv = document.getElementById('qb-hud-score-val');
+ if (sv) {
+ const sc = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ if (sv.textContent !== String(sc)) sv.textContent = String(sc);
+ }
+ const av = document.getElementById('qb-hud-host-av');
+ if (av) {
+ const cid = me.characterId || getPlayCharacterId();
+ if (cid && av.dataset.cid !== String(cid)) {
+ av.dataset.cid = String(cid);
+ /* full-body down avatar (เหมือนหน้า Quiz-Battle/.qb-room-profile-avatar) clip โชว์แค่ส่วนบน */
+ ensurePlayLobbyIdleAvatarImage(cid);
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) {
+ av.src = src;
+ } else {
+ const im = getCharacterImg(cid, 'down');
+ if (im && im.src) av.src = im.src;
+ }
+ }
+ }
+ }
+
+ function resetQuizCarryPlayState() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ const qcmo = document.getElementById('quiz-carry-mission-overlay');
+ if (qcmo) qcmo.classList.add('is-hidden');
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPool = [];
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryAnswerTimeupAwaitNext = false;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryEmbedCountdownOverlay();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ }
+
+ /** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
+ async function mergeQuizCarrySettingsFromDisk(s) {
+ const out = s && typeof s === 'object' ? s : {};
+ try {
+ const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
+ if (rd.ok) {
+ const disk = await rd.json();
+ if (disk && typeof disk === 'object') {
+ if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
+ out.carryMapPanelTheme = disk.carryMapPanelTheme;
+ }
+ if (disk.quizMapPanelTheme && typeof disk.quizMapPanelTheme === 'object') {
+ out.quizMapPanelTheme = disk.quizMapPanelTheme;
+ }
+ if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
+ out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
+ }
+ if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
+ out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
+ } else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
+ out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
+ }
+ if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
+ if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
+ if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
+ if (disk.carryWalkSpeedMultForMapId != null) {
+ out.carryWalkSpeedMultForMapId = String(disk.carryWalkSpeedMultForMapId).trim();
+ }
+ if (disk.carryWalkSpeedMult != null) {
+ const wm = Number(disk.carryWalkSpeedMult);
+ if (Number.isFinite(wm)) out.carryWalkSpeedMult = wm;
+ }
+ const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
+ if (Number.isFinite(diskPlaqueScale)) {
+ out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
+ }
+ if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
+ out.carryQuestions = disk.carryQuestions;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ return out;
+ }
+
+ async function loadQuizCarryPoolAndStart() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ quizCarrySessionLength = 0;
+ let pool = [];
+ let s = {};
+ const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === 'object') s = parsed;
+ }
+ }
+ } catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ }
+ try {
+ s = await mergeQuizCarrySettingsFromDisk(s);
+ } catch (e) { /* ignore */ }
+ if (snap) {
+ if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs;
+ if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs;
+ if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength;
+ }
+ if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') {
+ s.carryMapPanelTheme = snap.carryMapPanelTheme;
+ }
+ if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') {
+ s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme;
+ }
+ /* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */
+ if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) {
+ if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) {
+ s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes;
+ } else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') {
+ s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme;
+ }
+ }
+ if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) {
+ const sm = Number(snap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm;
+ }
+ if (snap) {
+ if ((s.carryWalkSpeedMultForMapId == null || String(s.carryWalkSpeedMultForMapId).trim() === '') && snap.carryWalkSpeedMultForMapId != null) {
+ s.carryWalkSpeedMultForMapId = snap.carryWalkSpeedMultForMapId;
+ }
+ if (s.carryWalkSpeedMult == null && snap.carryWalkSpeedMult != null) s.carryWalkSpeedMult = snap.carryWalkSpeedMult;
+ }
+ applyQuizCarryWalkSpeedFromSettingsObj(s);
+ {
+ const sc = Number(s.carryChoicePlaqueMapScale);
+ quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25;
+ }
+ try {
+ quizCarryCarryTimingMs = {
+ carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
+ carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
+ };
+ quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
+ setQuizCarryMapPanelThemeFromApi(s);
+ setQuizCarryEmbedCountdownThemeFromApi(s);
+ setQuizCarryChoicePlaqueThemeFromApi(s);
+ for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) {
+ const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl);
+ if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue;
+ const pim = new Image();
+ quizCarryApplyImageCrossOrigin(pim, tUrl);
+ pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(tUrl, pim);
+ try {
+ pim.src = tUrl;
+ } catch (e) { /* ignore */ }
+ }
+ const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
+ const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
+ for (let i = 0; i < source.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(source[i]);
+ if (n) pool.push(n);
+ }
+ } catch (e) { /* map only */ }
+ if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
+ quizCarryPool = pool;
+ for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]);
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (!pool.length) {
+ playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ return;
+ }
+ if (quizCarryEmbedMissionFlowActive()) {
+ beginQuizCarryEmbedPregame();
+ } else {
+ quizCarryPickNextQuestion();
+ }
+ const nowJoin = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowJoin) && !(quizCarryEmbedPreOptionCountdownEndAt > nowJoin)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ }
+
+ function setupPlayQuizCarryUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ } else {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ }
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = isDetectiveMinigamePlay()
+ ? ''
+ : (quizCarryMapHasAnswerInteractive(mapData)
+ ? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
+ : 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย');
+ }
+ resetQuizCarryPlayState();
+ if (previewMode) {
+ loadQuizCarryPoolAndStart();
+ } else {
+ playQuizText = isDetectiveMinigamePlay() ? '' : 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
+ loadQuizCarryPoolAndStart();
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ syncQuizCarryGrabButton();
+ }
+
+ function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const want = optionIndex + 1;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryHub(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryInteractive(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive && md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileForQuizCarrySubmit(md, o) {
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ const p = pickRandomTileInQuizCarryInteractive(md, o);
+ if (p) return p;
+ }
+ return pickRandomTileInQuizCarryHub(md, o);
+ }
+
+ /** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */
+ function quizCarryPreviewBotLobbyWanderStep(o, w, h) {
+ const now = Date.now();
+ o.botIsWalking = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED * quizCarryWalkSpeedMultActive;
+ const nx = o.x + accX * step;
+ const ny = o.y + accY * step;
+ const ox = o.x;
+ const oy = o.y;
+ if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ }
+
+ function stepQuizCarryPreviewBots() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!playBotsEnabled() || !isQuizCarry() || !mapData) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const nowPf = performance.now();
+ const pathfindMinGap = editorEmbedReturn ? 280 : 140;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
+ const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
+ if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
+ o.x = sn.x;
+ o.y = sn.y;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ if (!o.botPath || o.botPath.length === 0) {
+ tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
+ }
+ /* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */
+ if (!quizCarryCurrent) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const tier = o.botTier || 'avg';
+ const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
+ const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
+ : Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
+ const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
+ if (!o.botPath || !o.botPath.length) {
+ if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
+ if (nowPf < o.botQuizCarryPathfindAfter) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
+ if (quizCarryOptionsPickableNow()) {
+ if (o.quizCarryHeld == null && wantIdx != null) {
+ const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
+ if (dest) {
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ } else {
+ const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
+ if (sub) {
+ const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ }
+ o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
+ } else {
+ o.botQuizCarryPathfindAfter = nowPf + 120;
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ }
+ } else {
+ stepPreviewBotAlongPath(o, w, h);
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ }
+
+ function getStackAreaBoundsPlay(area, w, h) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let any = false;
+ for (let y = 0; y < h; y++) {
+ const row = area && area[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) {
+ any = true;
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ if (!any) return null;
+ return {
+ minX, maxX, minY, maxY,
+ cx: (minX + maxX + 1) / 2,
+ cy: (minY + maxY + 1) / 2,
+ };
+ }
+
+ /** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */
+ function getStackTowerCameraFollowPlayConfig() {
+ return { enabled: false, pxPerLayer: 12, maxPx: 260 };
+ }
+
+ /** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */
+ function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) {
+ if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0;
+ const n = Math.floor(Number(layerCount) || 0);
+ if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0;
+ const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3));
+ const tiers = n - (STACK_TOWER_SWING_LIFT_FROM_LAYER - 1);
+ return Math.min(lh * 20, tiers * lh * 0.98);
+ }
+
+ /**
+ * progress ≥เกณฑ์ Tower + ชั้นพอ: ไม่วาดชั้นล่าง (ข้าม draw) — ตำแหน่ง Y ยังใช้ชั้นจริง (floorY-(i+1)*h)
+ * เพื่อไม่ให้กองลอยหลุดจากฐานแมปเมื่อเทียบกริด
+ */
+ function getStackTowerVisualHiddenLayerCountPlay() {
+ if (!isStackTowerMissionHudActivePlay() || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) return 0;
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ if (n < 4) return 0;
+ return Math.floor(n / 2);
+ }
+
+ /**
+ * world px ตามความสูงหอจริง (mnn93hpi) — คูณ z แล้วได้พิกเซลจอสำหรับเลื่อนแถบ BG
+ * ใช้ n × layerWorldH (ความสูงชั้นตามบล็อก) คล้มด้วย maxPx จากแมป
+ */
+ function getStackTowerBgScrollHeightBoostPx() {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const cfg = getStackTowerCameraFollowPlayConfig();
+ if (!cfg.enabled || cfg.pxPerLayer <= 0) return 0;
+ const lh = Math.max(10, Number(stackMini.layerWorldH) || Math.max(14, (tileSize || 32) * 0.31));
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ let layerCount = n;
+ if (stackFall) {
+ const dur = Math.max(1, stackFall.dur || 400);
+ const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur));
+ layerCount = n + u * 0.9;
+ }
+ return Math.min(cfg.maxPx, layerCount * lh);
+ }
+
+ /** กล้อง stack: จุดกลางจาก land / release ของแมป (Tower ไม่เลื่อน cy ตามกอง) */
+ function getStackCameraCentersPx() {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let cx = (w / 2) * ts;
+ let cy = (h / 2) * ts;
+ if (land && rel) {
+ cx = land.cx * ts;
+ cy = ((land.cy + rel.cy) / 2) * ts;
+ } else if (land) {
+ cx = land.cx * ts;
+ cy = land.cy * ts;
+ } else if (rel) {
+ cx = rel.cx * ts;
+ cy = rel.cy * ts;
+ }
+ return { px: cx, py: cy };
+ }
+
+ /** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
+ function reapplyStackMiniSizingFromGlobals() {
+ if (!mapData || !isStack() || !stackMini) return;
+ if (stackMini.layers && stackMini.layers.length > 0) return;
+ if (stackFall || stackMini.settling) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackMini.initialWidthTiles = iw;
+ stackMini.widthTiles = iw;
+ markStackNextDropVisualDirty();
+ }
+
+ function resetStackMinigameState() {
+ stackMini = null;
+ stackTowerPost50AnimStartMs = null;
+ stackTowerHeartMinusFx = null;
+ if (!mapData || !isStack()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let towerCX = w * 0.5;
+ let swingAmp = 1.85;
+ let floorWorldY = (h - 1) * tileSize;
+ let swingWorldY = floorWorldY - tileSize * 1.35;
+ if (land) {
+ towerCX = land.cx;
+ floorWorldY = (land.maxY + 1) * tileSize;
+ const span = Math.max(1, land.maxX - land.minX + 1);
+ swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
+ }
+ if (rel) {
+ swingWorldY = (rel.cy + 0.5) * tileSize;
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
+ }
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackFall = null;
+ const towerMission = isStackTowerMissionUiMapPlay();
+ const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul;
+ const missMax = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ stackMini = {
+ topCenterX: towerCX,
+ widthTiles: initW,
+ initialWidthTiles: initW,
+ craneWorldX: towerCX * tileSize,
+ swingAmp,
+ phase: Math.random() * Math.PI * 2,
+ phaseSpeed: playStackSwingHz,
+ floorWorldY,
+ swingWorldY,
+ layerWorldH: towerMission ? towerLayerStripH : Math.max(14, tileSize * 0.3),
+ blockStripH: towerMission ? towerLayerStripH : Math.max(16, tileSize * 0.32),
+ layers: [],
+ lives: 3,
+ teamMissesLeft: towerMission ? missMax : null,
+ progressPct: 0,
+ score: 0,
+ combo: 0,
+ lastDropAt: 0,
+ stackTiltRad: 0,
+ stackTiltVel: 0,
+ nextDropVisualDirty: true,
+ pendingDropSeat: 1,
+ pendingDropHeavy: false,
+ scorePopups: [],
+ perfectStackX2Fx: null,
+ };
+ if (playBotsEnabled()) {
+ me.stackPreviewHumanPts = 0;
+ stackPreviewHudLog = [];
+ }
+ ensureStackTowerSlingImagePlay();
+ if (towerMission || playBotsEnabled()) {
+ ensureStackTowerLifeHudImagesPlay();
+ ensureStackTowerHeartMinusImgPlay();
+ }
+ updateStackTowerSwingYForStripGapPlay();
+ }
+
+ function stackEaseDrop(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t;
+ }
+
+ /** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
+ function stackEaseDropPhys(t, sloppyN) {
+ t = Math.max(0, Math.min(1, t));
+ if (sloppyN < 0.14) return t * t;
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ }
+
+ /**
+ * @param {object} hit
+ * @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
+ */
+ function applyStackHitFromDrop(hit, previewAttribution, layerVisualMeta) {
+ if (!stackMini || !hit) return;
+ const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (hit.miss) {
+ stackMini.combo = 0;
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ if (stackMini.layers && stackMini.layers.length) {
+ for (let gi = 0; gi < stackMini.layers.length; gi++) {
+ const lay = stackMini.layers[gi];
+ if (lay && lay.perfectGlowUntil != null) delete lay.perfectGlowUntil;
+ }
+ }
+ if (towerLive && stackMini.teamMissesLeft != null) {
+ stackMini.teamMissesLeft = Math.max(0, stackMini.teamMissesLeft - 1);
+ } else {
+ stackMini.lives = Math.max(0, stackMini.lives - 1);
+ if (stackMini.lives <= 0) {
+ stackMini.score = Math.max(0, stackMini.score - 2);
+ stackMini.lives = 3;
+ stackMini.layers = [];
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ stackMini.widthTiles = stackMini.initialWidthTiles;
+ const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
+ stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
+ stackMini.combo = 0;
+ stackMini.stackTiltRad = 0;
+ stackMini.stackTiltVel = 0;
+ }
+ }
+ if (towerLive) {
+ triggerStackTowerHeartMinusFxPlay();
+ const remain = stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : Math.max(0, (stackMini.lives || 0));
+ mg3TermOnMiss(Number(remain) || 0);
+ }
+ } else {
+ const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat();
+ const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy);
+ const placedW = hit.placedW != null && Number.isFinite(hit.placedW)
+ ? Math.max(0.85, Math.min(3.2, hit.placedW))
+ : stackMini.initialWidthTiles;
+ stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay });
+ const nAfter = stackMini.layers.length;
+ maybeQueueStackTowerBgScrollStepPlay(nAfter);
+ const perfectFull =
+ towerLive &&
+ hit.perfect &&
+ !hit.miss &&
+ nAfter >= 2 &&
+ hit.supportRatio != null &&
+ hit.supportRatio >= STACK_TOWER_PERFECT_SUPPORT_MIN;
+ if (perfectFull) {
+ const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS;
+ stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil;
+ stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil;
+ }
+ if (towerLive && (perfectFull || hit.pts >= 20)) {
+ ensureStackTowerPerfectFxImagesPlay();
+ stackMini.perfectStackX2Fx = {
+ until: performance.now() + STACK_TOWER_PERFECT_X2_MS,
+ topLayerIndex: nAfter - 1,
+ };
+ } else if (towerLive) {
+ stackMini.perfectStackX2Fx = null;
+ }
+ stackMini.score += hit.pts;
+ if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) {
+ let np = Number(stackMini.progressPct) + Number(hit.progressDelta);
+ np = Math.min(100, Math.max(0, np));
+ if (np > 99.999) np = 100;
+ stackMini.progressPct = np;
+ markStackNextDropVisualDirty();
+ }
+ if (playBotsEnabled() && previewAttribution) {
+ if (previewAttribution.human) {
+ me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
+ } else if (previewAttribution.botId) {
+ const ob = others.get(previewAttribution.botId);
+ if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
+ }
+ }
+ stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
+ stackMini.topCenterX = hit.placedCx;
+ if (towerLive && !hit.miss && hit.pts > 0) {
+ const pNow = Number(stackMini.progressPct);
+ const prog100 = Number.isFinite(pNow) && pNow >= 99.5;
+ const kind20 = hit.pts >= 20 || prog100;
+ if (!stackMini.scorePopups) stackMini.scorePopups = [];
+ const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS;
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' });
+ if (kind20 && perfectFull && nAfter >= 2) {
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' });
+ }
+ while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift();
+ }
+ if (towerLive && !hit.miss) mg3TermOnPlace(perfectFull || !!hit.perfect, stackMini.progressPct);
+ }
+ }
+
+ /**
+ * Editor embed เท่านั้น: ปรับ `stackMini.phase` ให้ sin·swingAmp ใกล้จุดรองรับก่อนปล่อย — บอทฉลาด/กลาง/พลาดบ่อย ต่างค่า aim error
+ */
+ function previewBotAlignStackSwingPhasePlay(m, botPeer) {
+ if (!playBotsEnabled() || !m || !mapData) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg';
+ const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05);
+ const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
+ const tri = Math.random() + Math.random() - 1;
+ let errTiles = tri * errMax * layerTight;
+ if (m.layers.length >= 2) {
+ const L = m.layers.length;
+ const step = m.layers[L - 1].cx - m.layers[L - 2].cx;
+ errTiles -= step * 0.28;
+ }
+ let targetCx = supportCx + errTiles;
+ if (land && m.layers.length > 0) {
+ const pull = (land.cx - supportCx) * 0.1;
+ const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
+ targetCx += Math.max(-cap, Math.min(cap, pull));
+ }
+ const rawWant = targetCx - m.topCenterX;
+ const amp = Math.max(1e-5, m.swingAmp);
+ const s = Math.max(-1, Math.min(1, rawWant / amp));
+ const phi0 = Math.asin(s);
+ const phi1 = Math.PI - phi0;
+ const twoPi = Math.PI * 2;
+ function nearestPhase(anchor, p) {
+ let best = p;
+ let bestD = Infinity;
+ for (let k = -5; k <= 5; k++) {
+ const cand = p + k * twoPi;
+ const d = Math.abs(cand - anchor);
+ if (d < bestD) {
+ bestD = d;
+ best = cand;
+ }
+ }
+ return best;
+ }
+ const n0 = nearestPhase(m.phase, phi0);
+ const n1 = nearestPhase(m.phase, phi1);
+ m.phase = Math.abs(n0 - m.phase) <= Math.abs(n1 - m.phase) ? n0 : n1;
+ }
+
+ /**
+ * ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน)
+ * พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*)
+ */
+ function computeStackDropResult(m) {
+ const raw = m.swingAmp * Math.sin(m.phase);
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ const wMul = getStackTowerBlockWorldScalePlay();
+ const fallWEff = fallW * wMul;
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const c1 = m.topCenterX + raw;
+ const half1 = fallWEff * 0.5;
+ const half0 = (m.layers.length === 0 ? supportW : supportW * wMul) * 0.5;
+ const left = Math.max(supportCx - half0, c1 - half1);
+ const right = Math.min(supportCx + half0, c1 + half1);
+ const overlap = right - left;
+ const missThreshold =
+ m.layers.length === 0
+ ? Math.max(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND)
+ : Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER);
+ const overlapMiss = overlap < missThreshold;
+ const placedCx = c1;
+ let driftMiss = false;
+ if (!overlapMiss && m.layers.length > 0) {
+ const anchorCx = land ? land.cx : wMap * 0.5;
+ let maxDriftTiles;
+ if (land) {
+ const landSpan = Math.max(1, land.maxX - land.minX + 1);
+ maxDriftTiles = landSpan * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND;
+ } else {
+ maxDriftTiles = Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT);
+ }
+ if (Math.abs(placedCx - anchorCx) > maxDriftTiles) driftMiss = true;
+ }
+ const miss = overlapMiss || driftMiss;
+ const delta = c1 - supportCx;
+ const span = supportW * 0.5 + fallW * 0.5;
+ const offN = Math.abs(delta) / Math.max(0.01, span);
+ const perfect = !miss && offN < 0.055;
+ const comboBefore = m.combo || 0;
+ let pts;
+ let progressDelta = 0;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live') {
+ const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1;
+ const progPerLayer = 100 / stackTowerProgressBlocksPlay();
+ pts = miss ? 0 : (10 * mult);
+ progressDelta = miss ? 0 : (progPerLayer * mult);
+ } else {
+ const bonus = perfect ? Math.min(4, comboBefore) : 0;
+ pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
+ }
+ const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0;
+ return {
+ miss,
+ perfect,
+ pts,
+ progressDelta,
+ landOffsetTiles: raw,
+ placedW: fallW,
+ placedCx,
+ overlap,
+ supportRatio,
+ delta,
+ };
+ }
+
+ /**
+ * เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
+ * @param {object} hit จาก computeStackDropResult
+ * @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
+ * @returns {boolean}
+ */
+ function startStackFallAnimation(hit, previewActor) {
+ if (!stackMini || !hit || stackFall || stackMini.settling) return false;
+ ensureStackNextDropVisual();
+ const m = stackMini;
+ const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat();
+ const actorHeavy = !!m.pendingDropHeavy;
+ const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay();
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh);
+ const swingAttachY = m.swingWorldY - swingLift;
+ let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
+ if (y1 <= swingAttachY) y1 = swingAttachY + lh * 0.55;
+ const landOff = hit.landOffsetTiles || 0;
+ const landCxWorld = (m.topCenterX + landOff) * tileSize;
+ const xLeft0 = swingXW - tw / 2;
+ const xLeft1 = landCxWorld - tw / 2;
+ const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
+ const dur = Math.max(400, Math.min(1280, 240 + (y1 - swingAttachY) * 0.92 + offN * 560));
+ const tiltMax = hit.miss
+ ? 0.28 * (0.25 + offN)
+ : 0.16 * offN * (hit.perfect ? 0.1 : 1);
+ const isHumanPreview = !!(previewActor && previewActor.human);
+ const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
+ const cableTopYRel = isStackTowerMissionUiMapPlay()
+ ? (swingAttachY - tileSize * 6)
+ : Math.max(0, swingAttachY - tileSize * 6);
+ const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ const releaseRopeAng = Math.atan2(swingAttachY - cableTopYRel, swingXW - craneXRel);
+ stackFall = {
+ t0: performance.now(),
+ dur,
+ xLeft0,
+ xLeft1,
+ tw,
+ y0: swingAttachY,
+ y1,
+ hit,
+ tiltMax,
+ sloppyN: offN,
+ previewHumanDrop: isHumanPreview,
+ previewBotId: botIdPreview,
+ releaseRopeAng,
+ releaseAttachWorldX: swingXW,
+ releaseAttachWorldY: swingAttachY,
+ actorSeat,
+ actorHeavy,
+ };
+ return true;
+ }
+
+ function stackHumanDropGateOkPlay() {
+ if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false;
+ if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
+ if (Date.now() - stackMini.lastDropAt < 480) return false;
+ return true;
+ }
+
+ /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
+ function tryHumanStackDrop() {
+ if (!stackHumanDropGateOkPlay()) return false;
+ ensureStackNextDropVisual();
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
+ if (playBotsEnabled()) {
+ lastStackPreviewActorId = '__human__';
+ lastStackPreviewActorUntil = Date.now() + 900;
+ }
+ return true;
+ }
+
+ /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
+ function simulatePreviewBotStackDrop(botId, o) {
+ if (!stackMini || stackFall || stackMini.settling) return;
+ ensureStackNextDropVisual();
+ previewBotAlignStackSwingPhasePlay(stackMini, o);
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
+ o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ lastStackPreviewActorId = botId;
+ lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ }
+
+ /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
+ function stepPreviewStackTurnBased(nowMs) {
+ if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return;
+ if (mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ const order = stackPreviewTurnOrder;
+ if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
+ const cur = order[stackPreviewTurnIndex];
+ if (!cur || cur.kind !== 'bot') return;
+ if (!cur.botId) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ const o = others.get(cur.botId);
+ if (!o) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ if (stackPreviewBotThinkUntil === 0) {
+ const tier = o.botTier === 'sharp' ? 'sharp' : o.botTier === 'weak' ? 'weak' : 'avg';
+ const thinkLo = tier === 'sharp' ? 160 : tier === 'avg' ? 260 : 340;
+ const thinkHi = tier === 'sharp' ? 340 : tier === 'avg' ? 520 : 620;
+ stackPreviewBotThinkUntil = nowMs + thinkLo + Math.random() * (thinkHi - thinkLo);
+ return;
+ }
+ if (nowMs < stackPreviewBotThinkUntil) return;
+ stackPreviewBotThinkUntil = 0;
+ simulatePreviewBotStackDrop(cur.botId, o);
+ }
+
+ function stackTickFrame() {
+ stackTowerMissionMaybeEndPlay();
+ const now = performance.now();
+ const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
+ lastStackTickMs = now;
+ if (stackMini && stackMini.settling) {
+ const s = stackMini.settling;
+ const u = (now - s.t0) / s.dur;
+ if (u >= 1) {
+ const wasHuman = !!s.previewHumanDrop;
+ const settleBotId = s.previewBotId || null;
+ applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
+ if (playBotsEnabled() && (wasHuman || settleBotId)) {
+ stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
+ }
+ stackMini.settling = null;
+ stackMini.lastDropAt = Date.now();
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn();
+ }
+ } else if (stackFall) {
+ const t = (now - stackFall.t0) / stackFall.dur;
+ if (t >= 1) {
+ const wasHuman = !!stackFall.previewHumanDrop;
+ const fallBotId = stackFall.previewBotId || null;
+ const hit = stackFall.hit;
+ const stableMin = STACK_STABLE_SUPPORT_RATIO_MIN;
+ if (hit.miss) {
+ spawnStackMissDebrisPlay(stackFall);
+ applyStackHitFromDrop(hit);
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ } else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
+ const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
+ stackMini.settling = {
+ t0: performance.now(),
+ dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
+ hit,
+ xLeft0: stackFall.xLeft1,
+ yTop0: stackFall.y1,
+ twWorld: stackFall.tw,
+ stripH,
+ delta: hit.delta || 0,
+ previewHumanDrop: wasHuman,
+ previewBotId: fallBotId,
+ actorSeat: stackFall.actorSeat,
+ actorHeavy: stackFall.actorHeavy,
+ };
+ stackFall = null;
+ } else {
+ let previewAttr;
+ if (playBotsEnabled()) {
+ if (wasHuman) previewAttr = { human: true };
+ else if (fallBotId) previewAttr = { botId: fallBotId };
+ }
+ applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy });
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ }
+ }
+ } else if (stackMini) {
+ stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
+ }
+ stepPreviewStackTurnBased(Date.now());
+ }
+
+ /**
+ * จุดบนจอที่เส้นก้าน→บล็อกตัดแนว y = -(ความสูงแคนวาส × STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC)
+ * — ปลายเชือกอยู่เหนือขอบบน ~25% ของความสูงจอ (มองเห็นช่วงที่เข้ามาในจอ)
+ */
+ function stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, canvasH) {
+ const ch = Math.max(80, Number(canvasH) || 1080);
+ const marginTop = -ch * STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC;
+ const ddx = tcx - slingBx;
+ const ddy = tcy - slingBy;
+ if (Math.abs(ddy) < 1e-4) {
+ return { x: tcx, y: Math.min(marginTop, Math.min(tcy, slingBy)) };
+ }
+ const vTop = (marginTop - slingBy) / ddy;
+ if (!Number.isFinite(vTop) || vTop < 0) {
+ return { x: tcx, y: tcy };
+ }
+ const xTop = slingBx + vTop * ddx;
+ return { x: xTop, y: marginTop };
+ }
+
+ function applyStackPreviewSpawnLayout() {
+ if (!mapData || mapData.gameType !== 'stack') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const cx = land ? land.cx : w / 2 - 0.5;
+ const cy = land ? land.cy : h / 2 - 0.5;
+ me.x = cx;
+ me.y = cy;
+ me.tx = cx;
+ me.ty = cy;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.x = cx + (Math.random() - 0.5) * 0.15;
+ o.y = cy + (Math.random() - 0.5) * 0.15;
+ o.tx = o.x;
+ o.ty = o.y;
+ o.stackBotScore = 0;
+ o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
+ });
+ }
+
+ function drawStackMinigame(ctx, worldToScreen, zoom) {
+ if (!stackMini) return;
+ const m = stackMini;
+ const towerHudLive = isStackTowerMissionHudActivePlay();
+ const wMul = getStackTowerBlockWorldScalePlay();
+ ensureStackNextDropVisual();
+ const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
+ const floorY = m.floorWorldY;
+ const swingLiftDraw = getStackTowerSwingLiftWorldPx(m.layers.length, layerWorldH);
+ const swingDrawY = m.swingWorldY - swingLiftDraw;
+ const hidLayers = towerHudLive ? getStackTowerVisualHiddenLayerCountPlay() : 0;
+
+ const nLay = m.layers.length;
+ for (let i = hidLayers; i < nLay; i++) {
+ const L = m.layers[i];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (i + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const hue = (i * 47) % 360;
+ const seatL = L.seat != null ? L.seat : 1;
+ const heavyL = !!L.heavy;
+ if (towerHudLive && L.perfectGlowUntil != null) {
+ const ntGlow = performance.now();
+ if (ntGlow < L.perfectGlowUntil) {
+ drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil);
+ }
+ }
+ drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {});
+ }
+ if (towerHudLive && m.perfectStackX2Fx) {
+ const fxX2 = m.perfectStackX2Fx;
+ const ntX = performance.now();
+ if (ntX >= fxX2.until) {
+ m.perfectStackX2Fx = null;
+ } else {
+ const ti = fxX2.topLayerIndex;
+ if (ti >= hidLayers && ti >= 0 && ti < m.layers.length) {
+ const Ltop = m.layers[ti];
+ const lwT = (Ltop.w != null ? Ltop.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxWT = Ltop.cx * tileSize;
+ const ybT = floorY - (ti + 1) * layerWorldH;
+ const xlT = cxWT - lwT / 2;
+ const [sxT, syT] = worldToScreen(xlT, ybT);
+ const drawWT = lwT * zoom;
+ const drawHT = layerWorldH * zoom;
+ const fxImX = ensureStackTowerPerfectFxImagesPlay();
+ if (fxImX.x2) {
+ drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until);
+ }
+ }
+ }
+ }
+ if (towerHudLive) {
+ drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY);
+ }
+ if (m.settling) {
+ const s = m.settling;
+ const u = Math.min(1, (performance.now() - s.t0) / s.dur);
+ const ue = u * u;
+ const rotSign = s.delta >= 0 ? 1 : -1;
+ const rotRad = ue * 1.22 * rotSign;
+ const yFall = ue * ue * tileSize * 6.5;
+ const xSlide = rotSign * ue * tileSize * 0.62;
+ const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
+ const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
+ const [cxs, cys] = worldToScreen(cxW, cyW);
+ const drawW = s.twWorld * zoom;
+ const drawH = s.stripH * zoom;
+ const hue = (m.layers.length * 47) % 360;
+ const seatS = s.actorSeat != null ? s.actorSeat : 1;
+ const heavyS = !!s.actorHeavy;
+ ctx.save();
+ ctx.translate(cxs, cys);
+ ctx.rotate(rotRad);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {});
+ ctx.restore();
+ } else {
+ const twWorld = m.widthTiles * tileSize * wMul;
+ const drawStripPx = stripH * zoom;
+ const cableTopY = isStackTowerMissionUiMapPlay()
+ ? (swingDrawY - tileSize * 6)
+ : Math.max(0, swingDrawY - tileSize * 6);
+ const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ let blockLeftWorld;
+ let blockTopWorld;
+ let fallTiltRad = 0;
+ if (stackFall) {
+ const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
+ const t = Math.min(1, tRaw);
+ const u = stackEaseDropPhys(t, stackFall.sloppyN);
+ blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
+ blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
+ fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
+ } else {
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ blockLeftWorld = swingXW - twWorld / 2;
+ blockTopWorld = swingDrawY;
+ }
+ const cxMid = blockLeftWorld + twWorld / 2;
+ const [tcx, tcy] = worldToScreen(craneX, cableTopY);
+ const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
+ let slingBx = bcx;
+ let slingBy = bcy;
+ if (stackFall && stackFall.releaseAttachWorldX != null) {
+ const attY = stackFall.releaseAttachWorldY;
+ const p = worldToScreen(stackFall.releaseAttachWorldX, attY);
+ slingBx = p[0];
+ slingBy = p[1];
+ }
+ let ropeSx0 = tcx;
+ let ropeSy0 = tcy;
+ let ropeSx1 = slingBx;
+ let ropeSy1 = slingBy;
+ if (isStackTowerMissionUiMapPlay()) {
+ const chCv = ctx.canvas && ctx.canvas.height ? ctx.canvas.height : STACK_TOWER_FIXED_RENDER_H;
+ const topA = stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, chCv);
+ ropeSx0 = topA.x;
+ ropeSy0 = topA.y;
+ ropeSx1 = slingBx;
+ ropeSy1 = slingBy;
+ const t1 = Math.max(0, Math.min(1, STACK_TOWER_ROPE_DRAW_T1));
+ if (t1 < 1) {
+ ropeSx1 = ropeSx0 + (slingBx - ropeSx0) * t1;
+ ropeSy1 = ropeSy0 + (slingBy - ropeSy0) * t1;
+ }
+ }
+ drawStackSlingSegmentPlay(ctx, ropeSx0, ropeSy0, ropeSx1, ropeSy1, zoom);
+ const ropeAng = stackFall && stackFall.releaseRopeAng != null
+ ? stackFall.releaseRopeAng
+ : Math.atan2(bcy - ropeSy0, bcx - ropeSx0);
+ const swingTiltRad = ropeAng - Math.PI / 2;
+ const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
+ const seatDraw = stackFall && stackFall.actorSeat != null
+ ? stackFall.actorSeat
+ : (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat());
+ const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy;
+ const hueHint = (m.layers.length * 47) % 360;
+ ctx.save();
+ ctx.translate(bcx, bcy);
+ ctx.rotate(fallTiltRad + swingTiltRad);
+ drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint });
+ ctx.lineWidth = 1;
+ ctx.restore();
+ }
+ if (towerHudLive) {
+ drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay);
+ }
+ drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorY);
+ }
+
+ function applyGauntletTimingFromServer(payload) {
+ if (!payload || typeof payload !== 'object') return;
+ const tm = Number(payload.gauntletTickMs);
+ if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
+ const jt = Number(payload.gauntletJumpTicks);
+ if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
+ const tl = Number(payload.gauntletTimeLimitSec);
+ if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
+ if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
+ else {
+ const ge = Number(payload.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) {
+ gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
+ if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
+ gauntletLaneImageUrls = payload.gauntletLaneImageUrls
+ .filter((x) => typeof x === 'string')
+ .map((x) => normalizeGauntletAssetUrlForPlay(x))
+ .filter((x) => x)
+ .slice(0, 24);
+ gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
+ gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '');
+ ensureGauntletAssetImage(gauntletLaserTopUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
+ gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '');
+ ensureGauntletAssetImage(gauntletLaserBottomUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
+ gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '');
+ ensureGauntletAssetImage(gauntletLaserLineUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
+ gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
+ gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
+ const lw = Number(payload.gauntletLaserLineWidthPx);
+ if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
+ const sh = Number(payload.stackSwingHz);
+ if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
+ const raw = payload.stackBlockWidthTiles;
+ if (raw == null || raw === '') playStackBlockWidthTiles = null;
+ else {
+ const nw = Number(raw);
+ playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
+ ? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
+ : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerMissionTimeSec')) {
+ const st = Number(payload.stackTowerMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playStackTowerMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playStackTowerMissionTimeSec = 90;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTeamMissesMax')) {
+ const sm = Number(payload.stackTeamMissesMax);
+ if (Number.isFinite(sm)) {
+ playStackTeamMissesMax = Math.max(1, Math.min(20, Math.floor(sm)));
+ } else {
+ playStackTeamMissesMax = 3;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerProgressBlocks')) {
+ const pb = Number(payload.stackTowerProgressBlocks);
+ if (Number.isFinite(pb) && pb >= 1) {
+ playStackTowerProgressBlocks = Math.max(1, Math.min(500, Math.floor(pb)));
+ } else {
+ playStackTowerProgressBlocks = 50;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) {
+ playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) {
+ playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) {
+ const hp = Number(payload.stackHeavyBlockPercent);
+ if (Number.isFinite(hp)) {
+ playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp)));
+ } else {
+ playStackHeavyBlockPercent = 35;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
+ const jm = Number(payload.jumpSurviveJumpHeightMult);
+ if (Number.isFinite(jm)) {
+ playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) {
+ const jt = Number(payload.jumpSurviveMissionTimeSec);
+ if (Number.isFinite(jt) && jt > 0) {
+ playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt)));
+ } else {
+ playJumpSurviveMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTiles')
+ || Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')) {
+ const legacy = Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')
+ ? normalizeGauntletAssetUrlForPlay(typeof payload.jumpSurvivePlatformTileUrl === 'string' ? payload.jumpSurvivePlatformTileUrl : '')
+ : '';
+ const arr = Array.isArray(payload.jumpSurvivePlatformTiles) ? payload.jumpSurvivePlatformTiles : [];
+ const next = [];
+ for (let pi = 0; pi < 3; pi++) {
+ const o = arr[pi] && typeof arr[pi] === 'object' ? arr[pi] : {};
+ let url = normalizeGauntletAssetUrlForPlay(typeof o.url === 'string' ? o.url : '');
+ if (pi === 0 && !url && legacy) url = legacy;
+ let ww = Number(o.w);
+ let hh = Number(o.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ next.push({ url, w: ww, h: hh });
+ if (url) ensureGauntletAssetImage(url);
+ }
+ playJumpSurvivePlatformTiles = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) {
+ const st = Number(payload.spaceShooterMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playSpaceShooterMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipImageUrls')) {
+ const a = payload.spaceShooterShipImageUrls;
+ const next = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ next[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipImageUrls = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidSpriteUrls')) {
+ const raw = payload.spaceShooterAsteroidSpriteUrls;
+ const next = [];
+ if (Array.isArray(raw)) {
+ for (let ai = 0; ai < raw.length && next.length < 32; ai++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof raw[ai] === 'string' ? raw[ai] : '');
+ if (u) next.push(u);
+ }
+ }
+ playSpaceShooterAsteroidSpriteUrls = next;
+ next.forEach((u) => {
+ if (u) ensureGauntletAssetImage(u);
+ });
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidExplodeFrameMs')) {
+ const ef = Number(payload.spaceShooterAsteroidExplodeFrameMs);
+ if (Number.isFinite(ef)) {
+ playSpaceShooterAsteroidExplodeFrameMs = Math.max(30, Math.min(500, Math.round(ef)));
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidIntervalMs')) {
+ const iv = Number(payload.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(iv) && iv >= 200) {
+ playSpaceShooterAsteroidIntervalMs = Math.min(10000, Math.floor(iv));
+ } else {
+ playSpaceShooterAsteroidIntervalMs = 1040;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipDamageOverlayUrls')) {
+ const rawD = payload.spaceShooterShipDamageOverlayUrls;
+ const nextD = ['', '', ''];
+ if (Array.isArray(rawD)) {
+ for (let di = 0; di < 3; di++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof rawD[di] === 'string' ? rawD[di] : '');
+ nextD[di] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipDamageOverlayUrls = nextD;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) {
+ const st = Number(payload.balloonBossMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playBalloonBossMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) {
+ playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : ''
+ );
+ if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
+ const a = payload.balloonBossPlayerBalloonImageUrls;
+ const nextBb = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ nextBb[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playBalloonBossPlayerBalloonImageUrls = nextBb;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) {
+ playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : ''
+ );
+ if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl);
+ }
+ if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
+ }
+
+ /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
+ const GAUNTLET_VIS_LERP = 0.42;
+
+ function gauntletObsSmoothstep(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t * (3 - 2 * t);
+ }
+
+ function cloneGauntletObsSnap(arr) {
+ if (!Array.isArray(arr)) return [];
+ return arr.map((o) => {
+ if (!o || o.id == null) return null;
+ const row = { id: o.id, kind: o.kind, x: o.x, y: o.y };
+ if (o.kind === 'laser') {
+ if (o.y0 != null) row.y0 = o.y0;
+ if (o.y1 != null) row.y1 = o.y1;
+ }
+ return row;
+ }).filter(Boolean);
+ }
+
+ function gauntletLaserRowHitRange(ob, mapH) {
+ const h = Math.max(1, Math.floor(Number(mapH)) || 15);
+ let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0;
+ let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1;
+ y0 = Math.max(0, Math.min(h - 1, y0));
+ y1 = Math.max(0, Math.min(h - 1, y1));
+ if (y1 < y0) {
+ const t = y0;
+ y0 = y1;
+ y1 = t;
+ }
+ return { y0, y1 };
+ }
+
+ function gauntletLaserOverlapsPlayerRow(ob, py, mapH) {
+ const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH);
+ return py >= y0 && py <= y1;
+ }
+
+ /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
+ function gauntletLiftHeightNorm(air, jumpTicksMax) {
+ const jm = Math.max(4, jumpTicksMax || 16);
+ const a = Math.max(0, Math.min(jm, Number(air) || 0));
+ const t = a / jm;
+ /* อาร์คกระโดดนุ่มแบบ design (rn-jump): sine โค้งสมมาตร ease-in-out peak กลางอากาศ
+ เดิม: ขาขึ้น quadratic / ขาลง linear (อสมมาตร peak ~72% เวลา → ขาลงกระตุก) */
+ const p = Math.max(0, Math.min(1, 1 - t)); /* 0 = เริ่มกระโดด, 1 = แตะพื้น */
+ return Math.sin(Math.PI * p);
+ }
+
+ /* ===== Minigame-2: ชื่อ+คะแนนใต้ตัวละคร + เลข -10 เป็น DOM overlay (sync world→screen) แทนวาดบน canvas =====
+ เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */
+ const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */
+ const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */
+ function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) {
+ const ov = document.getElementById('gauntlet-name-overlay');
+ if (!ov) return;
+ const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
+ if (!live || typeof worldToScreenFn !== 'function') {
+ if (!ov.classList.contains('is-hidden')) {
+ ov.classList.add('is-hidden');
+ gauntletDomLabelPool.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); });
+ gauntletDomLabelPool.clear();
+ }
+ return;
+ }
+ ov.classList.remove('is-hidden');
+ const canvas = document.getElementById('game-canvas');
+ const stage = document.getElementById('play-canvas-stage');
+ if (!canvas || !stage) return;
+ const cr = canvas.getBoundingClientRect();
+ const srect = stage.getBoundingClientRect();
+ const dispScale = canvas.width > 0 ? cr.width / canvas.width : 1;
+ ov.style.left = Math.round(cr.left - srect.left) + 'px';
+ ov.style.top = Math.round(cr.top - srect.top) + 'px';
+ ov.style.width = canvas.width + 'px';
+ ov.style.height = canvas.height + 'px';
+ ov.style.transform = 'scale(' + dispScale + ')';
+ const fp = (typeof getCharacterFootprintWH === 'function') ? getCharacterFootprintWH(mapData) : { cw: 1, ch: 1 };
+ const cwv = fp.cw || 1, chv = fp.ch || 1;
+ function v(n) { const x = Number(n); return Number.isFinite(x) ? x : 0; }
+ const seen = new Set();
+ function place(id, ent, isMe) {
+ if (!ent) return;
+ if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'
+ && shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return;
+ seen.add(id);
+ const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0);
+ const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * 0.52 : 0;
+ const cxWorld = (v(ent.x) + cwv * 0.5) * tileSizePx;
+ const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY;
+ const scr = worldToScreenFn(cxWorld, cyBottomWorld);
+ const sx = scr[0], sy = scr[1];
+ /* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร
+ (กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */
+ const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0;
+ /* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */
+ var sbEl = document.querySelector('.play-cyber-scoreboard');
+ var sbBottomBuf = sbEl ? ((sbEl.getBoundingClientRect().bottom - cr.top) / (dispScale || 1) + 6) : 0;
+ if (pen > (gauntletPenaltySeenById.get(id) || 0) && (sy - 28) > sbBottomBuf) {
+ gauntletPenaltySeenById.set(id, pen);
+ /* -10 float = design asset score-.png (รูป "-10" สำเร็จรูป) ไม่ใช่ text สไตล์เอง */
+ const pf = document.createElement('img');
+ pf.className = 'gnt-pen';
+ pf.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ pf.alt = '-10';
+ pf.decoding = 'async';
+ pf.style.left = Math.round(sx) + 'px';
+ pf.style.top = Math.round(sy - 28) + 'px';
+ ov.appendChild(pf);
+ setTimeout(function () { if (pf.parentNode) pf.parentNode.removeChild(pf); }, 900);
+ } else if (pen > (gauntletPenaltySeenById.get(id) || 0)) {
+ /* อยู่บริเวณแถบคะแนน — บันทึกว่าเห็นแล้ว ไม่ต้องสร้าง float (กัน spawn ซ้ำตอนเลื่อนลงมา) */
+ gauntletPenaltySeenById.set(id, pen);
+ }
+ }
+ if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false));
+ place('__me', me, true);
+ }
+
+ function getGauntletObsDrawPositionsAt(nowMs) {
+ if (!gauntletObsRenderNext.length) return [];
+ const elapsed = nowMs - gauntletObsBlendT0;
+ const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs);
+ const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o]));
+ const out = [];
+ for (let i = 0; i < gauntletObsRenderNext.length; i++) {
+ const n = gauntletObsRenderNext[i];
+ if (!n) continue;
+ const p = prevMap.get(n.id);
+ let drawX = n.x;
+ if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha;
+ const row = { id: n.id, kind: n.kind, drawX, y: n.y };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ out.push(row);
+ }
+ return out;
+ }
+
+ function pushGauntletObsRenderFrame(newObs) {
+ const now = performance.now();
+ const next = cloneGauntletObsSnap(newObs);
+ if (!gauntletObsRenderNext.length) {
+ gauntletObsRenderPrev = next.slice();
+ gauntletObsRenderNext = next.slice();
+ } else {
+ const curDraw = getGauntletObsDrawPositionsAt(now);
+ const curMap = new Map(curDraw.map((o) => [o.id, o.drawX]));
+ gauntletObsRenderPrev = next.map((n) => {
+ const row = {
+ id: n.id,
+ kind: n.kind,
+ x: curMap.has(n.id) ? curMap.get(n.id) : n.x,
+ y: n.y,
+ };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ return row;
+ });
+ gauntletObsRenderNext = next;
+ }
+ gauntletObsBlendT0 = now;
+ }
+
+ function lerpGauntletEntityPos(x, y, tx, ty) {
+ let nx = x;
+ let ny = y;
+ if (tx != null && Number.isFinite(tx)) {
+ nx += (tx - nx) * GAUNTLET_VIS_LERP;
+ if (Math.abs(tx - nx) < 0.02) nx = tx;
+ }
+ if (ty != null && Number.isFinite(ty)) {
+ ny += (ty - ny) * GAUNTLET_VIS_LERP;
+ if (Math.abs(ty - ny) < 0.02) ny = ty;
+ }
+ return { nx, ny };
+ }
+
+ /**
+ * บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท)
+ * — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป
+ */
+ function maybePreviewBotGauntletJump(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ if ((o.gauntletJumpTicks || 0) > 0) return;
+ const { ch: gauntletCh } = getCharacterFootprintWH(mapData);
+ let sameCell = false;
+ let incomingCol = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const obs = obstacles[i];
+ if (!obs) continue;
+ if (obs.kind === 'lane' && typeof obs.y === 'number') {
+ if (obs.x === px && obs.y >= py && obs.y < py + gauntletCh) sameCell = true;
+ if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true;
+ }
+ if (obs.kind === 'laser' && typeof obs.x === 'number') {
+ if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true;
+ if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) incomingCol = true;
+ }
+ }
+ const tier = o.botTier || 'avg';
+ const roll = Math.random();
+ const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86;
+ const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72;
+ if (sameCell && roll < pSame) {
+ o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ return;
+ }
+ if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ }
+
+ /** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */
+ function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ const air = (o.gauntletJumpTicks || 0) > 0;
+ const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData);
+ const crown = isGauntletCrownHeistMapPlay();
+ let advanceX = false;
+ let hitBack = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const ob = obstacles[i];
+ if (!ob) continue;
+ if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y >= py && ob.y < py + gauntletCh2) {
+ if (air) advanceX = true;
+ else hitBack = true;
+ }
+ if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) {
+ if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) {
+ /* เลเซอร์ไม่ครอบแถวนี้ */
+ } else if (air) {
+ advanceX = true;
+ } else {
+ hitBack = true;
+ }
+ }
+ }
+ if (advanceX) {
+ px = Math.min(w - 2, px + 1);
+ o.gauntletJumpTicks = 0;
+ if (!crown) {
+ o.gauntletScore = (o.gauntletScore || 0) + 1;
+ }
+ } else if (hitBack) {
+ if (crown) {
+ if (px <= 0) {
+ o.gauntletEliminated = true;
+ o.gauntletScore = 0;
+ } else {
+ o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10);
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ px = Math.max(0, px - 1);
+ }
+ } else {
+ px = Math.max(0, px - 1);
+ }
+ }
+ if (crown && mapData) {
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx != null && Number.isFinite(mx)) {
+ const maxPx = Math.floor(mx + 1e-6);
+ px = Math.min(px, maxPx);
+ }
+ }
+ if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
+ o.tx = px;
+ o.ty = py;
+ }
+
+ function applyGauntletPreviewBotsAfterSync() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) return;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o) return;
+ maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
+ applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
+ });
+ }
+
+ /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
+ function emitGauntletPreviewRowsToServer() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const h = mapData.height || 15;
+ const ysSet = new Set();
+ const addY = (v) => {
+ const fy = Math.floor(Number(v));
+ if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
+ };
+ addY(me.y);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) addY(o.y);
+ });
+ socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
+ }
+
+ /** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */
+ function hideConflictingOverlaysForGauntletCrown() {
+ hideStackTowerResultFlashDomOnlyPlay();
+ hideQuizCarryPregameOverlay();
+ quizCarryPregameActive = false;
+ const hideIds = [
+ 'quiz-game-overlay',
+ 'quiz-carry-mission-overlay',
+ 'gauntlet-ended-overlay',
+ 'quiz-battle-mcq-overlay',
+ 'quiz-carry-embed-countdown',
+ 'gauntlet-crown-countdown',
+ 'quiz-carry-embed-q-strip',
+ 'play-quiz-scoreboard',
+ 'play-quiz-feedback',
+ ];
+ for (let i = 0; i < hideIds.length; i++) {
+ const el = document.getElementById(hideIds[i]);
+ if (el) el.classList.add('is-hidden');
+ }
+ const gcm = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcm) gcm.classList.add('is-hidden');
+ hideStackTowerHowtoRulesDom();
+ const gchHowto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (gchHowto) gchHowto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+
+ function gcmRankBadgeUrl(rank) {
+ const r = Math.floor(Number(rank)) || 99;
+ if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png';
+ if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png';
+ if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png';
+ return BASE + '/img/gauntlet-assets/result-txt-2.png';
+ }
+
+ /** สรุปภารกิจแบบ mockup — ต่อมินิเกม mission map (Minigame 1/4–7 + Last Light mno9kb07) */
+ function isGcmMissionMockSummaryPlay(mission) {
+ if (!mission || !mission.uiSkin) {
+ return isGauntletCrownHeistMapPlay();
+ }
+ if (mission.uiSkin === 'gauntlet_crown') return isGauntletCrownHeistMapPlay();
+ if (mission.uiSkin === 'question_mission') return isQuizQuestionMissionUiMapPlay();
+ if (mission.uiSkin === 'jumper') return isJumpSurviveMissionUiMapPlay();
+ if (mission.uiSkin === 'violent_crime') return isSpaceShooterMissionUiMapPlay();
+ if (mission.uiSkin === 'mega_virus') return isMegaVirusMissionShellMapPlay();
+ if (mission.uiSkin === 'stack_tower') return isStackTowerMissionUiMapPlay();
+ if (mission.uiSkin === 'quiz_carry') return quizCarryEmbedMissionFlowActive() || isQuizCarry();
+ return false;
+ }
+
+ function gcmMissionMockAssetUrl(mission, file) {
+ if (!mission) {
+ return isGauntletCrownHeistMapPlay()
+ ? (BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, ''))
+ : questionMissionAssetUrl(file);
+ }
+ const skin = mission.uiSkin;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(file);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(file);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(file);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(file);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + String(file || '').replace(/^\//, '');
+ return questionMissionAssetUrl(file);
+ }
+
+ function resolveGauntletCrownMissionUiSkinPlay(mission) {
+ if (mission && mission.uiSkin) return mission.uiSkin;
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return mission && mission.uiSkin;
+ }
+
+ function setGauntletCrownMg2CountdownGraphic(numEl, textEl, n) {
+ const d = Math.max(1, Math.min(3, n));
+ if (textEl) {
+ textEl.textContent = String(d);
+ textEl.setAttribute('aria-label', String(d));
+ }
+ if (numEl) {
+ numEl.removeAttribute('src');
+ numEl.alt = '';
+ }
+ }
+
+ function syncGauntletCrownCountdownMg2ModePlay(cd) {
+ if (!cd) return;
+ const mg2 = isGauntletCrownHeistMapPlay();
+ cd.classList.toggle('gcc-mg2-mock', mg2);
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ if (numEl) {
+ numEl.classList.toggle('gcc-num--img', !mg2);
+ numEl.classList.toggle('is-hidden', !!mg2);
+ numEl.setAttribute('aria-hidden', mg2 ? 'true' : 'false');
+ }
+ if (textEl) {
+ textEl.classList.toggle('is-hidden', !mg2);
+ textEl.setAttribute('aria-hidden', mg2 ? 'false' : 'true');
+ }
+ }
+
+ function gauntletCrownPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (gauntletCrownLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function gauntletCrownSyncGuestReadyIfNeeded() {
+ const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto';
+ const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto';
+ const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto';
+ const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto';
+ if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return;
+ if (myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (gauntletCrownLobbyReadyMap[sid]) return;
+ gauntletCrownLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true });
+ }
+
+ /** Minigame-2 mno9kb07 — HOW TO PLAY ใช้ layout mock 01-howto (canvas = mg-bg) */
+ function gauntletCrownHowtoUsesMg2LayoutPlay() {
+ return !!isGauntletCrownHeistMapPlay();
+ }
+ function getGauntletCrownHowtoStatusEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('gauntlet-crown-howto-status-mg2');
+ }
+ return document.getElementById('gauntlet-crown-howto-status');
+ }
+ function getGauntletCrownHowtoBtnEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('btn-gch-ready-mg2');
+ }
+ return document.getElementById('btn-gch-ready');
+ }
+ function setPlayMg2HowtoLivePlay(on) {
+ document.documentElement.classList.toggle('play-mg2-howto-live', !!on);
+ }
+ function clearGchMg2OverlayBoundsPlay(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ ['position', 'left', 'top', 'width', 'height', 'right', 'bottom', 'transform'].forEach((p) => {
+ ov.style.removeProperty(p);
+ });
+ }
+ function dismissGauntletCrownHowtoOverlayPlay() {
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ setPlayMg2HowtoLivePlay(false);
+ clearGchMg2OverlayBoundsPlay(howto);
+ }
+ gauntletCrownHowtoVisible = false;
+ }
+
+ /** ภารกิจคำถาม mng8a80o — Ready Status / ปุ่ม แบบเดียวกับ Mega Virus (gauntlet-crown-lobby-sync) */
+ function updateQuizQuestionMissionHowtoHud() {
+ if (!isQuizQuestionMissionUiMapPlay() || quizQuestionMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function updateGauntletCrownHowtoHud() {
+ const st = getGauntletCrownHowtoStatusEl();
+ const btn = getGauntletCrownHowtoBtnEl();
+ if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ if (st) {
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ }
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ const btnImg = document.getElementById('btn-gch-ready-mg2-img');
+ if (btnImg) {
+ btnImg.src = humansReady
+ ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png'))
+ : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png'));
+ btnImg.alt = humansReady ? 'START' : 'READY';
+ }
+ }
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginGauntletCrownCountdownThenRun() {
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'countdown';
+ if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ }
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ dismissGauntletCrownHowtoOverlayPlay();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ function applyMegaBalloonLiveStartPlay() {
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastTickMs = performance.now();
+ balloonBossGameEnded = false;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossBossFireAcc = 0;
+ balloonBossScorePopups = [];
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (mapData) applyBalloonBossSpawnLayoutPlay();
+ }
+ const runFinish = () => {
+ if (cd) cd.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ if (socket && socket.connected) {
+ socket.emit('gauntlet-crown-begin-run', (res) => {
+ if (res && res.ok) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ });
+ } else {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ const mg2Cd = isGauntletCrownHeistMapPlay();
+ cd.classList.remove('is-hidden');
+ let n = 3;
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = () => {
+ n--;
+ if (n > 0) {
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ } else {
+ gauntletCrownCountdownTimer = null;
+ runFinish();
+ }
+ };
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ function showGauntletCrownHowtoOverlay() {
+ if (!usesCrownLobbyShellPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ if (isGauntletCrownHeistMapPlay()) applyGauntletCrownHeistMissionPanelImages();
+ else if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) {
+ cd.classList.add('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ }
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'howto';
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ if (isGauntletCrownHeistMapPlay()) {
+ setPlayMg2HowtoLivePlay(true);
+ updateGchTfMockScale(ov);
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */
+ function gauntletCrownEmbedMissionAnyOk(mission) {
+ if (!mission) return false;
+ if (mission.uiSkin === 'violent_crime') {
+ const sc = Number(mission.survivorCount);
+ if (Number.isFinite(sc) && sc <= 0) return false;
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'jumper') {
+ const sc = Number(mission.survivorCount);
+ return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F');
+ }
+ if (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'mega_virus') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+
+ function gauntletCrownRankBonusLocal(rankOrdinal) {
+ if (rankOrdinal === 1) return 100;
+ if (rankOrdinal === 2) return 80;
+ if (rankOrdinal === 3) return 60;
+ return 0;
+ }
+
+ function gauntletCrownRollRewardCardLocal(grade) {
+ const r = Math.random();
+ if (grade === 'A') {
+ if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ /** พรีวิว: สรุปภารกิจ Last Light หลังรันเวย์ครบ — ให้ gauntletCrownBuildDisplayMission รวมบอท */
+ function gauntletCrownHeistBuildLocalCrownMissionPlay() {
+ const baseRows = [];
+ if (myId != null) {
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o || isPreviewBotId(id)) return;
+ baseRows.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const bs = Math.max(0, Number(row.baseScore) | 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus,
+ finalScore: bs + rankBonus,
+ };
+ });
+ const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = baseRows.filter((r) => !r.eliminated).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ uiSkin: 'gauntlet_crown',
+ ranked,
+ totalSum,
+ averageScore,
+ grade,
+ rewardCard,
+ totalParts: ranked.map((r) => r.finalScore),
+ participantCount: baseRows.length,
+ survivorCount,
+ };
+ }
+
+ /**
+ * Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์
+ * — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row)
+ */
+ function gauntletCrownBuildDisplayMission(mission) {
+ if (!mission || !Array.isArray(mission.ranked)) return mission;
+ if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission;
+ if (!(playBotsEnabled() && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission;
+
+ const seen = new Set();
+ const baseRows = [];
+ mission.ranked.forEach((r) => {
+ if (!r) return;
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: r.id,
+ nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น',
+ characterId: r.characterId ?? null,
+ baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ });
+ });
+ others.forEach((o, id) => {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ /* รวมตัวเรา (human) ถ้ายังไม่อยู่ — บาง mission (preview/จาก server) มีแต่บอท ทำให้ตัวเราตกหายจาก summary */
+ if (myId != null && !seen.has(String(myId))) {
+ seen.add(String(myId));
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 รวมตัวเรา (เดิม 5 ตัดคนคะแนนต่ำสุด/ตายออก) */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos);
+ const finalScore = row.baseScore + rankBonus;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: rankLabel,
+ rankBonus: rankBonus,
+ finalScore: finalScore,
+ };
+ });
+
+ const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ const rewardCard = gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: ranked.map(function (r) { return r.finalScore; }),
+ };
+ }
+
+ /* ===== ชุดคำถามพิเศษ (รับการ์ดพิเศษ) — โมดูลกลาง ใช้ได้หลายเกม =====
+ * จุดต่อเข้าเกมจริง (trigger) จะ wire ภายหลังตามที่ผู้ใช้ระบุ
+ * วิธีใช้: SpecialQuiz.fetchSettings() -> getSet(level,caseId) -> pickQuestions(set,n)
+ * เล่นจบแล้ว awardCard(set, correct, total) -> set mission.specialCardAwarded = card
+ */
+ let __specialQuizSettingsCache = null;
+ let __specialQuizFetchPromise = null;
+
+ function specialQuizFetchSettings() {
+ if (__specialQuizSettingsCache) return Promise.resolve(__specialQuizSettingsCache);
+ if (__specialQuizFetchPromise) return __specialQuizFetchPromise;
+ __specialQuizFetchPromise = fetch(BASE + '/api/quiz-settings', { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __specialQuizSettingsCache = j || {}; return __specialQuizSettingsCache; })
+ .catch(function () { __specialQuizFetchPromise = null; return {}; });
+ return __specialQuizFetchPromise;
+ }
+
+ function specialQuizCurrentLevelCase() {
+ const meta = window.__detectiveLobbyMeta || {};
+ let level = Number(meta.level);
+ let caseId = Number(meta.caseId);
+ if (!(level >= 1 && level <= 5)) {
+ const lp = Number(params.get('lobbyLevel'));
+ if (lp >= 1 && lp <= 5) level = lp;
+ }
+ if (!(caseId >= 1 && caseId <= 3)) {
+ const cp = Number(params.get('case') || params.get('caseId'));
+ if (cp >= 1 && cp <= 3) caseId = cp;
+ }
+ return {
+ level: (level >= 1 && level <= 5) ? level : 1,
+ caseId: (caseId >= 1 && caseId <= 3) ? caseId : 1,
+ };
+ }
+
+ function specialQuizGetSet(settings, level, caseId) {
+ const sets = (settings && settings.specialQuizSets) || {};
+ const key = 'L' + level + '_C' + caseId;
+ return sets[key] || sets.L1_C1 || { questions: [], specialCard: {} };
+ }
+
+ function specialQuizPickQuestions(set, count) {
+ const qs = (set && Array.isArray(set.questions)) ? set.questions.slice() : [];
+ for (let i = qs.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const t = qs[i]; qs[i] = qs[j]; qs[j] = t;
+ }
+ let n = Number(count);
+ if (!(n >= 1)) n = qs.length;
+ return qs.slice(0, Math.min(n, qs.length));
+ }
+
+ /** กติกาได้การ์ด: ตอบถูก >= ครึ่งหนึ่ง และชุดนั้นตั้งการ์ดไว้ — คืน card หรือ null */
+ function specialQuizAwardCard(set, correctCount, totalCount) {
+ const card = (set && set.specialCard) || null;
+ if (!card || !(card.th || card.en || card.imageUrl)) return null;
+ const total = Number(totalCount) || 0;
+ const correct = Number(correctCount) || 0;
+ const passNeed = total > 0 ? Math.ceil(total / 2) : 1;
+ if (correct < passNeed) return null;
+ return card;
+ }
+
+ /** วาดการ์ดพิเศษลงในโซนโบนัสของสรุปผล (#gcm-bonus / .sm-bonus-right) */
+ function renderSpecialCardInBonus(bonusEl, card) {
+ if (!bonusEl || !card || !(card.th || card.en || card.imageUrl || card.cardId)) return;
+ const imgUrl = card.imageUrl || (card.cardId >= 1 && card.cardId <= 7
+ ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (!imgUrl) return;
+ const isPresetAsset = imgUrl.indexOf('/img/special-cards/') >= 0 || imgUrl.indexOf('/trycss/10-Game-1-result/') >= 0;
+ if (bonusEl.classList && bonusEl.classList.contains('gcm-bonus-mg2-right')) {
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ img.decoding = 'async';
+ bonusEl.appendChild(img);
+ return;
+ }
+ const wrap = document.createElement('div');
+ wrap.className = isPresetAsset
+ ? 'sm-bonus-right gcm-special-card-preset'
+ : 'gcm-special-card sm-bonus-special rarity-' + (card.rarity || 'rare');
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ wrap.appendChild(img);
+ if (!isPresetAsset) {
+ const cap = document.createElement('div');
+ cap.className = 'gcm-special-card-cap';
+ const strong = document.createElement('strong');
+ strong.textContent = card.th || '';
+ const span = document.createElement('span');
+ span.textContent = card.en || '';
+ cap.appendChild(strong);
+ cap.appendChild(span);
+ wrap.appendChild(cap);
+ }
+ bonusEl.appendChild(wrap);
+ }
+
+ window.SpecialQuiz = {
+ fetchSettings: specialQuizFetchSettings,
+ currentLevelCase: specialQuizCurrentLevelCase,
+ getSet: specialQuizGetSet,
+ pickQuestions: specialQuizPickQuestions,
+ awardCard: specialQuizAwardCard,
+ renderCardInBonus: renderSpecialCardInBonus,
+ };
+
+ /* ===== Special Quiz ในเกม — ไอคอนในฉาก (เดินชน) + overlay คำถาม multiplayer =====
+ * server เป็นผู้ตัดสิน: สุ่มไอคอน 1/3, เปิดคำถามให้ทุกคนพร้อมกัน, ตอบถูกทุกคนทุกข้อ -> ได้การ์ด
+ */
+ const SPECIAL_QUIZ_CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ /** Test Mode (Admin) เก็บใน localStorage — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
+ function __isTestModeOnPlay() {
+ try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
+ }
+ let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
+ let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน)
+ let specialQuizCollideSent = false;
+ let specialQuizCollideCooldownUntil = 0;
+ let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended)
+ let specialQuizActiveQuestion = null; // { index, total, choices, endsAt, iconType }
+ let specialQuizAnswered = false;
+ let specialQuizTimerRaf = 0;
+ const specialQuizIconImgs = {};
+ /** หยุดเกมทุกอย่างระหว่างตอบคำถามพิเศษ แล้ว resume หลังจบ */
+ let specialQuizFreeze = false;
+ let specialQuizFreezeStartMs = 0;
+ let specialQuizRoster = []; // [{ id, nickname, isBot }]
+ let specialQuizAnswerStatus = {}; // id -> true (ตอบแล้ว)
+
+ let specialQuizFreezeLastTickMs = 0;
+
+ function beginSpecialQuizFreeze() {
+ if (specialQuizFreeze) return;
+ specialQuizFreeze = true;
+ specialQuizFreezeStartMs = Date.now();
+ specialQuizFreezeLastTickMs = Date.now();
+ try { for (const k in keys) keys[k] = false; } catch (e) { /* ignore */ }
+ try { playPath = []; } catch (e) { /* ignore */ }
+ if (me) me.isWalking = false;
+ }
+
+ /** เลื่อน deadline/anchor เวลาทุกตัวไปข้างหน้าเท่า delta — ใช้เรียกทุกเฟรมระหว่าง freeze
+ เพื่อให้ "เวลาที่แสดง" หยุดนิ่ง (ไม่ใช่แค่ชดเชยตอนจบ) */
+ function shiftSpecialQuizFrozenTimers(deltaMs) {
+ if (!(deltaMs > 0)) return;
+ const shift = (v) => (typeof v === 'number' && v > 0) ? v + deltaMs : v;
+ try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {}
+ try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {}
+ try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {}
+ try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {}
+ try { if (typeof jumpSurviveSessionStartMs === 'number' && jumpSurviveSessionStartMs > 0) jumpSurviveSessionStartMs += deltaMs; } catch (e) {}
+ try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {}
+ }
+
+ /** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
+ function tickSpecialQuizFreezeShift() {
+ if (!specialQuizFreeze) return;
+ const now = Date.now();
+ shiftSpecialQuizFrozenTimers(now - specialQuizFreezeLastTickMs);
+ specialQuizFreezeLastTickMs = now;
+ }
+
+ function endSpecialQuizFreeze() {
+ if (!specialQuizFreeze) return;
+ /* ชดเชย delta ที่เหลือตั้งแต่เฟรมสุดท้ายจนถึงตอนนี้ */
+ shiftSpecialQuizFrozenTimers(Date.now() - specialQuizFreezeLastTickMs);
+ specialQuizFreeze = false;
+ specialQuizFreezeStartMs = 0;
+ specialQuizFreezeLastTickMs = 0;
+ }
+
+ function specialQuizIconUrl(type) {
+ return '/Game/img/special-quiz/icon-' + (type === 'police' ? 'police' : 'lawyer') + '.png';
+ }
+ function specialQuizGetIconImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizIconImgs[key]) return specialQuizIconImgs[key];
+ const img = new Image();
+ img.src = specialQuizIconUrl(key);
+ specialQuizIconImgs[key] = img;
+ return img;
+ }
+ const specialQuizGlowImgs = {};
+ /** แสง glow (08-event light-*) วางหลังไอคอน — ให้ดูเป็นของเก็บเรืองแสงบนเส้นทาง */
+ function specialQuizGetGlowImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizGlowImgs[key]) return specialQuizGlowImgs[key];
+ const img = new Image();
+ img.src = '/Game/img/special-quiz/light-' + key + '.png';
+ specialQuizGlowImgs[key] = img;
+ return img;
+ }
+ function setSpecialQuizIconFromServer(payload) {
+ if (!payload || payload.x == null || payload.y == null) { specialQuizIcon = null; return; }
+ specialQuizIcon = {
+ iconType: payload.iconType === 'police' ? 'police' : 'lawyer',
+ x: Number(payload.x),
+ y: Number(payload.y),
+ };
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ specialQuizGetIconImg(specialQuizIcon.iconType);
+ }
+ function clearSpecialQuizIcon() {
+ specialQuizIcon = null;
+ specialQuizIconScreenRect = null;
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ }
+
+ /** ส่ง collide ไป server เพื่อเปิดคำถาม — ถ้า server ปฏิเสธ (เช่น race) ให้ลองใหม่ได้หลัง cooldown */
+ function sendSpecialQuizCollide() {
+ if (specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (Date.now() < specialQuizCollideCooldownUntil) return;
+ specialQuizCollideSent = true;
+ specialQuizCollideCooldownUntil = Date.now() + 800;
+ try {
+ socket.emit('special-quiz-collide', {}, function (res) {
+ if (!(res && res.ok) && !specialQuizActiveQuestion) specialQuizCollideSent = false;
+ });
+ } catch (e) { specialQuizCollideSent = false; }
+ }
+
+ /** กด/แตะไอคอนควิซเพื่อเปิดคำถาม (ใช้ได้ทุกมินิเกม รวม space shooter ที่เดินไปแตะไม่ได้) */
+ function trySpecialQuizClickAt(clientX, clientY) {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return false;
+ if (!specialQuizIconScreenRect || !canvas) return false;
+ const r = canvas.getBoundingClientRect();
+ if (!r.width || !r.height) return false;
+ const px = (clientX - r.left) * (canvas.width / r.width);
+ const py = (clientY - r.top) * (canvas.height / r.height);
+ const dx = px - specialQuizIconScreenRect.x;
+ const dy = py - specialQuizIconScreenRect.y;
+ /* เผื่อระยะกดให้กว้างขึ้น — แตะใกล้ไอคอนก็เปิดได้ */
+ const hitR = specialQuizIconScreenRect.r * 1.3;
+ if (dx * dx + dy * dy <= hitR * hitR) {
+ sendSpecialQuizCollide();
+ return true;
+ }
+ return false;
+ }
+
+ /** วาดไอคอนในฉาก — ใช้ worldToScreen + zDraw จาก draw() เพื่อให้ตรงทุกมินิเกม */
+ function drawSpecialQuizIconWorld(w2s, zDraw) {
+ if (!specialQuizIcon || typeof w2s !== 'function') return;
+ const img = specialQuizGetIconImg(specialQuizIcon.iconType);
+ const sc = w2s(specialQuizIcon.x * tileSize, specialQuizIcon.y * tileSize);
+ const sx = sc[0];
+ const sy = sc[1];
+ if (!Number.isFinite(sx) || !Number.isFinite(sy)) return;
+ const size = Math.max(30, tileSize * zDraw * 1.75);
+ const bob = Math.sin(Date.now() / 320) * size * 0.06;
+ const cy = sy + bob;
+ ctx.save();
+ /* glow 08-event (light-*) หมุน+เต้นหลังไอคอน — collectible เรืองแสง */
+ const glowImg = specialQuizGetGlowImg(specialQuizIcon.iconType);
+ if (glowImg && glowImg.complete && glowImg.naturalWidth > 0) {
+ const pulse = 0.82 + Math.sin(Date.now() / 300) * 0.18;
+ const gsz = size * 2.5 * pulse;
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ ctx.globalAlpha = 0.9;
+ ctx.translate(sx, cy);
+ ctx.rotate(Date.now() / 2600);
+ ctx.drawImage(glowImg, -gsz / 2, -gsz / 2, gsz, gsz);
+ ctx.restore();
+ }
+ const r = size * 0.62;
+ const grad = ctx.createRadialGradient(sx, cy, r * 0.18, sx, cy, r);
+ grad.addColorStop(0, 'rgba(122, 162, 247, 0.5)');
+ grad.addColorStop(1, 'rgba(122, 162, 247, 0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ /* เก็บตำแหน่งบนจอไว้ให้กด/แตะไอคอนได้ (สำหรับมินิเกมที่เดินไปแตะไม่ได้ เช่น space shooter) */
+ specialQuizIconScreenRect = { x: sx, y: cy, r: Math.max(size * 0.7, 36) };
+ if (img && img.complete && img.naturalWidth > 0) {
+ ctx.drawImage(img, sx - size / 2, cy - size / 2, size, size);
+ } else {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.fillRect(sx - size / 2, cy - size / 2, size, size);
+ }
+ ctx.fillStyle = '#ffd166';
+ ctx.font = '700 ' + Math.round(size * 0.42) + 'px system-ui, "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('?', sx + size * 0.4, cy - size * 0.38);
+ ctx.restore();
+ }
+
+ /** เช็คผู้เล่นเดินชนไอคอน — ใครชนก่อนเป็นคนเปิดคำถามให้ทั้งห้อง (server กันซ้ำ) */
+ function checkSpecialQuizCollision() {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (!Number.isFinite(me.x) || !Number.isFinite(me.y)) return;
+ const dx = me.x - specialQuizIcon.x;
+ const dy = me.y - specialQuizIcon.y;
+ /* รัศมีกว้างขึ้น (1.25 tile) — ไอคอนวาดใหญ่ 1.75 tile เดินเข้าใกล้ก็ทริกได้ ไม่ต้องทับ tile กลางพอดี */
+ if (dx * dx + dy * dy <= 1.25 * 1.25) {
+ sendSpecialQuizCollide();
+ }
+ }
+
+ function specialQuizOverlayEl() { return document.getElementById('special-quiz-overlay'); }
+
+ function specialQuizSelfId() { return myId != null ? String(myId) : null; }
+
+ function specialQuizMemberLabel(m) {
+ if (m && m.id && m.id === specialQuizSelfId()) return (m.nickname || 'คุณ') + ' (คุณ)';
+ return (m && m.nickname) ? m.nickname : '—';
+ }
+
+ function renderSpecialQuizMembers() {
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ box.innerHTML = '';
+ specialQuizRoster.forEach(function (m, i) {
+ const row = document.createElement('div');
+ row.className = 'sq-ov-member' + (m.id === specialQuizSelfId() ? ' sq-ov-member--me' : '');
+ row.dataset.id = m.id;
+ const dot = document.createElement('span');
+ dot.className = 'sq-ov-member-dot';
+ dot.textContent = String(i + 1);
+ const name = document.createElement('span');
+ name.className = 'sq-ov-member-name';
+ name.textContent = specialQuizMemberLabel(m);
+ const tag = document.createElement('span');
+ tag.className = 'sq-ov-member-tag';
+ tag.textContent = m.isBot ? 'บอท' : '';
+ const ans = document.createElement('span');
+ ans.className = 'sq-ov-member-ans';
+ ans.textContent = specialQuizAnswerStatus[m.id] ? '…' : '';
+ if (specialQuizAnswerStatus[m.id]) row.classList.add('sq-ov-member--answered');
+ row.appendChild(dot);
+ row.appendChild(name);
+ row.appendChild(tag);
+ row.appendChild(ans);
+ box.appendChild(row);
+ });
+ }
+
+ function markSpecialQuizMemberAnswered(id) {
+ if (!id) return;
+ specialQuizAnswerStatus[id] = true;
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ const row = box.querySelector('.sq-ov-member[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
+ if (!row) { renderSpecialQuizMembers(); return; }
+ row.classList.add('sq-ov-member--answered');
+ const ans = row.querySelector('.sq-ov-member-ans');
+ if (ans && !row.classList.contains('sq-ov-member--correct') && !row.classList.contains('sq-ov-member--wrong')) ans.textContent = '✓';
+ }
+
+ function openSpecialQuizQuestion(q) {
+ if (!q) return;
+ clearSpecialQuizIcon();
+ beginSpecialQuizFreeze();
+ specialQuizActiveQuestion = {
+ index: q.index | 0,
+ total: q.total | 0,
+ choices: Array.isArray(q.choices) ? q.choices : [],
+ endsAt: Number(q.endsAt) || 0,
+ iconType: q.iconType,
+ };
+ if (Array.isArray(q.roster) && q.roster.length) specialQuizRoster = q.roster.slice();
+ specialQuizAnswerStatus = {};
+ specialQuizAnswered = false;
+ const ov = specialQuizOverlayEl();
+ if (!ov) return;
+ const iconEl = document.getElementById('sq-ov-icon');
+ if (iconEl) iconEl.src = specialQuizIconUrl(q.iconType);
+ const prog = document.getElementById('sq-ov-progress');
+ if (prog) prog.textContent = 'ข้อ ' + ((q.index | 0) + 1) + ' / ' + (q.total | 0);
+ const qEl = document.getElementById('sq-ov-question');
+ if (qEl) qEl.textContent = q.text || '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) { result.classList.add('is-hidden'); result.textContent = ''; result.className = 'sq-ov-result is-hidden'; }
+ /* แสดงเฉพาะ layer คำถาม/คำตอบ — ซ่อน layer ผลรางวัล */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.remove('is-hidden');
+ const awardLayer = document.getElementById('sq-ov-award');
+ if (awardLayer) awardLayer.classList.add('is-hidden');
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'เลือกคำตอบ — ทุกคนต้องตอบถูกทุกข้อจึงได้การ์ดพิเศษ';
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ choicesEl.innerHTML = '';
+ specialQuizActiveQuestion.choices.forEach(function (c, i) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'sq-ov-choice';
+ b.dataset.idx = String(i);
+ const k = document.createElement('span');
+ k.className = 'sq-ov-choice-key';
+ k.textContent = SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1);
+ const t = document.createElement('span');
+ t.className = 'sq-ov-choice-text';
+ t.textContent = c;
+ b.appendChild(k);
+ b.appendChild(t);
+ b.onclick = function () { submitSpecialQuizAnswer(i); };
+ choicesEl.appendChild(b);
+ });
+ }
+ renderSpecialQuizMembers();
+ ov.classList.remove('is-hidden');
+ startSpecialQuizTimerLoop();
+ }
+
+ function submitSpecialQuizAnswer(i) {
+ if (specialQuizAnswered || !specialQuizActiveQuestion) return;
+ specialQuizAnswered = true;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ b.classList.toggle('sq-ov-choice--picked', Number(b.dataset.idx) === i);
+ b.disabled = true;
+ });
+ }
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'ส่งคำตอบแล้ว — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ try { socket.emit('special-quiz-answer', { choiceIndex: i }, function () {}); } catch (e) { /* ignore */ }
+ }
+
+ function startSpecialQuizTimerLoop() {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ function tick() {
+ if (!specialQuizActiveQuestion) return;
+ const timerEl = document.getElementById('sq-ov-timer');
+ const ms = specialQuizActiveQuestion.endsAt - Date.now();
+ const s = Math.max(0, Math.ceil(ms / 1000));
+ if (timerEl) {
+ timerEl.textContent = String(s);
+ timerEl.classList.toggle('sq-ov-timer--low', s <= 5);
+ }
+ if (ms > 0) specialQuizTimerRaf = requestAnimationFrame(tick);
+ }
+ tick();
+ }
+
+ function handleSpecialQuizResult(r) {
+ if (!r || !specialQuizActiveQuestion) return;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ const idx = Number(b.dataset.idx);
+ b.disabled = true;
+ if (idx === r.correctIndex) b.classList.add('sq-ov-choice--correct');
+ else if (b.classList.contains('sq-ov-choice--picked')) b.classList.add('sq-ov-choice--wrong');
+ });
+ }
+ /* เฉลย — เปิดเผยว่าแต่ละคนตอบอะไร ถูก/ผิด */
+ const answers = (r.answers && typeof r.answers === 'object') ? r.answers : {};
+ const labels = SPECIAL_QUIZ_CHOICE_LABELS;
+ const box = document.getElementById('sq-ov-members');
+ if (box) {
+ Array.prototype.forEach.call(box.children, function (row) {
+ const id = row.dataset.id;
+ const a = answers[id];
+ const ansEl = row.querySelector('.sq-ov-member-ans');
+ row.classList.remove('sq-ov-member--answered');
+ if (Number.isInteger(a)) {
+ const ok = a === r.correctIndex;
+ row.classList.add(ok ? 'sq-ov-member--correct' : 'sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = (ok ? '✓ ' : '✗ ') + (labels[a] || String(a + 1));
+ } else {
+ row.classList.add('sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = '—';
+ }
+ });
+ }
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = r.allCorrect
+ ? 'ทุกคนตอบถูก!'
+ : (r.timeout ? 'หมดเวลา — ตอบไม่ครบทุกคน' : 'มีคนตอบผิด — ไม่ได้การ์ดพิเศษ');
+ result.className = 'sq-ov-result ' + (r.allCorrect ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ cancelAnimationFrame(specialQuizTimerRaf);
+ }
+
+ /** การ์ดพิเศษถูก "ใช้" — โชว์ animation + ขยายเวลามินิเกมฝั่ง client (ถ้าเป็น Time Dilation) */
+ function applySpecialCardClient(data) {
+ const card = data.card || {};
+ const addSec = Number(data.addSec) || 0;
+ const coins = Number(data.coins) || 0;
+ if (addSec > 0 && card.effectKey === 'time_dilation') {
+ extendCurrentMinigameTimeClient(addSec);
+ }
+ if (coins > 0 && card.effectKey === 'fund') {
+ /* server แจกเหรียญแล้ว (game-award.php) — แค่ดึงยอดล่าสุดมาแสดง; ถ้าไม่ใช่ (เผื่อ fallback) ค่อยบวกเอง */
+ if (data.coinsServerAwarded) resyncOwnCoins();
+ else addOwnCoinsViaApi(coins);
+ }
+ /* ถ้า overlay ควิซยังเปิด (การ์ดเพิ่งได้จากควิซ) — แผง «เล่นต่อ» โชว์การ์ดอยู่แล้ว ไม่ต้องเด้งซ้ำ */
+ const sqOv = specialQuizOverlayEl();
+ if (sqOv && !sqOv.classList.contains('is-hidden')) return;
+ showSpecialCardUseAnimation(card, { addSec: addSec, coins: coins });
+ }
+
+ /** Card 6 Fund — แต่ละ client บวกเหรียญของตัวเอง (อ่าน jdPlayerKey จาก localStorage) */
+ function addOwnCoinsViaApi(amount) {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins-add.php') : '/Admin/api/player-coins-add.php');
+ try {
+ fetch(url, {
+ method: 'POST',
+ credentials: 'omit',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playerKey: key, amount: amount }),
+ }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** ดึงยอดเหรียญล่าสุดจาก server มาอัปเดต cache (ใช้หลัง server แจกเหรียญเอง เช่น Fund) */
+ function resyncOwnCoins() {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key);
+ try {
+ fetch(url, { credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */
+ function extendCurrentMinigameTimeClient(sec) {
+ const ms = Math.max(0, sec * 1000);
+ if (!ms) return;
+ if (isJumpSurvive() && jumpSurviveSessionStartMs) jumpSurviveSessionStartMs += ms;
+ if (isSpaceShooter() && spaceShooterSessionStartMs) spaceShooterSessionStartMs += ms;
+ if (isQuizCarry()) {
+ if (quizCarryAnswerCloseAt > 0) quizCarryAnswerCloseAt += ms;
+ if (quizCarryOptionRevealAt > 0) quizCarryOptionRevealAt += ms;
+ }
+ }
+
+ function showSpecialCardUseAnimation(card, opts) {
+ opts = opts || {};
+ let ov = document.getElementById('special-card-use-overlay');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'special-card-use-overlay';
+ ov.className = 'is-hidden';
+ ov.innerHTML =
+ '' +
+ '
';
+ document.body.appendChild(ov);
+ }
+ const img = ov.querySelector('.scu-img');
+ const nameEl = ov.querySelector('.scu-name');
+ const fxEl = ov.querySelector('.scu-effect');
+ if (img) img.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (nameEl) nameEl.textContent = 'ใช้การ์ด: ' + (card.en || card.th || 'Special Card');
+ if (fxEl) {
+ let fx = card.desc || card.th || '';
+ if (opts.addSec > 0) fx = '+' + opts.addSec + ' วินาที';
+ else if (opts.coins > 0) fx = 'ทุกคน +' + opts.coins + ' COINS';
+ fxEl.textContent = fx;
+ }
+ ov.classList.remove('is-hidden');
+ ov.classList.remove('scu-play');
+ void ov.offsetWidth;
+ ov.classList.add('scu-play');
+ clearTimeout(showSpecialCardUseAnimation._t);
+ showSpecialCardUseAnimation._t = setTimeout(function () {
+ ov.classList.add('is-hidden');
+ ov.classList.remove('scu-play');
+ }, 2600);
+ }
+
+ /** ปิด overlay + คืนเวลาเล่นต่อ (เรียกตอนกด «เล่นต่อ» หรือ safety timeout) */
+ function specialQuizContinueAndResume() {
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = 0;
+ const ov = specialQuizOverlayEl();
+ const award = document.getElementById('sq-ov-award');
+ if (award) award.classList.add('is-hidden');
+ if (ov) ov.classList.add('is-hidden');
+ /* แจ้ง server ให้เล่นรอบ quiz (mng8a80o) ต่อ — มินิเกมอื่น freeze ฝั่ง client พอ */
+ try { socket.emit('special-quiz-continue', {}, function () {}); } catch (e) { /* ignore */ }
+ endSpecialQuizFreeze();
+ }
+
+ function endSpecialQuiz(data) {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ specialQuizActiveQuestion = null;
+ specialQuizAnswered = false;
+ const success = !!(data && data.success && data.card);
+ const card = success ? data.card : null;
+ if (success) specialQuizAwardedCard = card;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = success ? 'ได้รับการ์ดพิเศษ!' : 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ result.className = 'sq-ov-result ' + (success ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ /* แผงโชว์การ์ด + ปุ่ม «เล่นต่อ» — เกมหยุดค้างจนกว่าจะกด */
+ const award = document.getElementById('sq-ov-award');
+ const titleEl = document.getElementById('sq-ov-award-title');
+ const imgEl = document.getElementById('sq-ov-award-img');
+ const nameEl = document.getElementById('sq-ov-award-name');
+ const fxEl = document.getElementById('sq-ov-award-effect');
+ const btn = document.getElementById('sq-ov-award-continue');
+ if (award) {
+ if (success) {
+ if (titleEl) titleEl.textContent = 'ได้รับการ์ดพิเศษ!';
+ if (imgEl) {
+ imgEl.removeAttribute('hidden');
+ imgEl.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ }
+ if (nameEl) nameEl.textContent = card.en || card.th || ('การ์ด #' + (card.cardId || ''));
+ if (fxEl) fxEl.textContent = card.desc || card.th || '';
+ } else {
+ if (titleEl) titleEl.textContent = 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ if (imgEl) { imgEl.setAttribute('hidden', ''); imgEl.removeAttribute('src'); }
+ if (nameEl) nameEl.textContent = '';
+ if (fxEl) fxEl.textContent = 'ตอบถูกครบทุกคนเพื่อรับการ์ดพิเศษในครั้งหน้า';
+ }
+ if (btn) btn.onclick = specialQuizContinueAndResume;
+ /* สลับ layer: ซ่อนคำถาม/คำตอบ แล้วโชว์ผลรางวัลเต็มหน้า */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.add('is-hidden');
+ award.classList.remove('is-hidden');
+ } else {
+ /* ไม่มี element สำรอง — เล่นต่อทันที */
+ specialQuizContinueAndResume();
+ return;
+ }
+ /* กันค้าง: ถ้าไม่กดภายใน 30 วิ ให้ไปต่อเอง */
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = setTimeout(specialQuizContinueAndResume, 30000);
+ }
+
+ function showGauntletCrownMissionOverlay(mission) {
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ const rowEl = document.getElementById('gcm-rank-row');
+ const totalEl = document.getElementById('gcm-total');
+ const gradeEl = document.getElementById('gcm-grade');
+ const bonusEl = document.getElementById('gcm-bonus');
+ const btn = document.getElementById('btn-gcm-done');
+ if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return;
+ if (!mission.uiSkin && isGauntletCrownHeistMapPlay()) mission.uiSkin = 'gauntlet_crown';
+ const missionSkin = resolveGauntletCrownMissionUiSkinPlay(mission);
+ if (!mission.specialCardAwarded && specialQuizAwardedCard) mission.specialCardAwarded = specialQuizAwardedCard;
+ let disp = gauntletCrownBuildDisplayMission(mission);
+ if (mission.uiSkin === 'jumper') {
+ disp = jumpSurviveMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'violent_crime') {
+ disp = spaceShooterMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'question_mission') {
+ disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'stack_tower') {
+ disp = stackTowerMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'mega_virus') {
+ disp = balloonBossMergePreviewBotsMission(disp) || disp;
+ }
+ if (!disp || !Array.isArray(disp.ranked)) return;
+ /* mission map — สรุปผลภารกิจตาม mockup (Minigame 1/4–7 + Last Light 07-summary) */
+ const tfMockSummary = isGcmMissionMockSummaryPlay(mission);
+ const mg2Summary = tfMockSummary && missionSkin === 'gauntlet_crown';
+ ov.classList.toggle('gcm-quiz-tf-mock', tfMockSummary);
+ ov.classList.toggle('gcm-mg2-mock', mg2Summary);
+ const gcmVrule = document.getElementById('gcm-vrule');
+ if (gcmVrule) {
+ gcmVrule.classList.toggle('is-hidden', !mg2Summary);
+ gcmVrule.setAttribute('aria-hidden', mg2Summary ? 'true' : 'true');
+ }
+ const gcmBgImg = ov.querySelector('.gcm-bg');
+ if (gcmBgImg) {
+ const gcmBgSrc = tfMockSummary
+ ? gcmMissionMockAssetUrl(mission, 'popup-result.png')
+ : (BASE + '/img/gauntlet-assets/popup-result.png');
+ if (gcmBgImg.getAttribute('src') !== gcmBgSrc) gcmBgImg.setAttribute('src', gcmBgSrc);
+ }
+ if (tfMockSummary) {
+ ensureMePlayTintFromLobbyStorage();
+ ensureQuizTfScaleResizeListener();
+ updateGcmTfMockScale(ov);
+ /* preload tint ทุกคนก่อนวาด avatar — กันรูปว่าง/สีเดียว */
+ disp.ranked.forEach(function (r) {
+ const cid = resolveRankedCharacterIdPlay(r);
+ if (!cid) return;
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }));
+ });
+ } else {
+ ov.style.removeProperty('--gcm-tf-scale');
+ }
+ rowEl.innerHTML = '';
+ disp.ranked.forEach((r) => {
+ const cell = document.createElement('div');
+ cell.className = 'gcm-cell';
+ const tag = document.createElement('div');
+ tag.className = 'gcm-rank-tag';
+ const rk = Math.floor(Number(r.rank)) || 0;
+ if (tfMockSummary) {
+ /* mockup: ทุกอันดับเป็นภาพ (result-1st/2nd/3rd, txt-1=4, txt-2=5+) */
+ const badge = rk === 1 ? 'result-1st.png' : rk === 2 ? 'result-2nd.png'
+ : rk === 3 ? 'result-3rd.png' : rk === 4 ? 'result-txt-1.png' : 'result-txt-2.png';
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmMissionMockAssetUrl(mission, badge);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else if (rk >= 1 && rk <= 3) {
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmRankBadgeUrl(rk);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else {
+ tag.textContent = '[' + String(r.rankLabel || rk) + ']';
+ }
+ const frame = document.createElement('div');
+ frame.className = 'gcm-frame';
+ const img = document.createElement('img');
+ img.className = 'gcm-av';
+ img.alt = '';
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ if (cid) {
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ } else {
+ img.src = defaultAvatarImg.src;
+ }
+ img.onerror = function () {
+ this.onerror = null;
+ applyCyberHudScoreAvatarImgWithRetry(this, avRow, 3);
+ };
+ if (tfMockSummary) {
+ /* mission mock: หน้าใน gcm-av-clip z3 ทับ player-frame z2 (กลางกรอบทึบ) */
+ const avClip = document.createElement('div');
+ avClip.className = 'gcm-av-clip';
+ avClip.appendChild(img);
+ frame.appendChild(avClip);
+ const frameImg = document.createElement('img');
+ frameImg.className = 'gcm-frame-img';
+ frameImg.src = gcmMissionMockAssetUrl(mission, 'player-frame.png');
+ frameImg.alt = '';
+ frameImg.decoding = 'async';
+ frame.appendChild(frameImg);
+ } else {
+ frame.appendChild(img);
+ }
+ const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong;
+ if (tfMockSummary && (r.eliminated || qMissionLose)) cell.classList.add('gcm-cell--out');
+ if (r.eliminated || qMissionLose) {
+ const st = document.createElement('img');
+ st.className = 'gcm-stamp';
+ const jSkin = mission && mission.uiSkin === 'jumper';
+ let loseUrl = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ let altLose = 'เสียชีวิต';
+ if (jSkin && r.eliminated) {
+ loseUrl = jumperAssetUrl('stamp-sacrifice.png');
+ altLose = 'ผู้เสียสละ';
+ } else if (qMissionLose) {
+ loseUrl = BASE + '/img/QUESTION/result-Lose_stamp.png';
+ altLose = 'ตอบผิด';
+ }
+ st.src = loseUrl;
+ st.alt = altLose;
+ st.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ this.alt = 'ตอบผิด';
+ };
+ frame.appendChild(st);
+ }
+ const nick = document.createElement('div');
+ nick.className = 'gcm-nick';
+ nick.textContent = r.nickname || '—';
+ const sc = document.createElement('div');
+ sc.className = 'gcm-sc';
+ const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus');
+ const botScorePlain = (playBotsEnabled() && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id) && !mg2Summary)
+ || (playBotsEnabled() && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id));
+ if (mg2Summary) {
+ const bs = Math.max(0, Number(r.baseScore) || 0);
+ const rb = Math.max(0, Number(r.rankBonus) || 0);
+ sc.textContent = '';
+ sc.appendChild(document.createTextNode(String(bs)));
+ if (rb > 0) {
+ const bon = document.createElement('span');
+ bon.className = 'gcm-sc-bonus';
+ bon.textContent = '(+' + String(rb) + ')';
+ sc.appendChild(bon);
+ }
+ } else {
+ sc.textContent = (jumpPlain || botScorePlain)
+ ? String(Math.max(0, Number(r.baseScore) || 0))
+ : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')';
+ }
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ if (totalEl) {
+ const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0)));
+ const sum = Math.max(0, Number(disp.totalSum) || 0);
+ const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0));
+ const jumperSkin = mission && mission.uiSkin === 'jumper';
+ const violentSkin = mission && mission.uiSkin === 'violent_crime';
+ const questionMissionSkin = mission && mission.uiSkin === 'question_mission';
+ if (tfMockSummary) {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') =' + sum + ' ';
+ } else if (jumperSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด';
+ } else if (violentSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · รอดชีวิต ' + sc + '/' + pc + ' → เกรด';
+ } else if (questionMissionSkin) {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนตอบคำถาม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'stack_tower') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'mega_virus') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด';
+ }
+ }
+ if (gradeEl) {
+ const g = String(disp.grade || 'C').toUpperCase().charAt(0);
+ gradeEl.textContent = g;
+ gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase();
+ }
+ if (bonusEl) {
+ bonusEl.innerHTML = '';
+ const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0);
+ const missionOk = gLetter !== 'F';
+ let bonusCardHost = bonusEl;
+ if (mg2Summary && missionOk) {
+ const bonusWrap = document.createElement('div');
+ bonusWrap.className = 'gcm-bonus-mg2 sm-bonus';
+ const bonusLeft = document.createElement('div');
+ bonusLeft.className = 'gcm-bonus-mg2-left sm-bonus-left';
+ const bonusHead = document.createElement('h3');
+ bonusHead.className = 'gcm-h gcm-bonus-mg2-head head';
+ bonusHead.textContent = 'โบนัสพิเศษ';
+ const completeImg = document.createElement('img');
+ completeImg.className = 'gcm-bonus-mg2-complete complete';
+ completeImg.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ completeImg.alt = 'ภารกิจสำเร็จ';
+ completeImg.decoding = 'async';
+ completeImg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ bonusLeft.appendChild(bonusHead);
+ bonusLeft.appendChild(completeImg);
+ const bonusRight = document.createElement('div');
+ bonusRight.className = 'gcm-bonus-mg2-right sm-bonus-right';
+ bonusWrap.appendChild(bonusLeft);
+ bonusWrap.appendChild(bonusRight);
+ bonusEl.appendChild(bonusWrap);
+ bonusCardHost = bonusRight;
+ } else if (missionOk) {
+ const row = document.createElement('div');
+ row.className = 'gcm-bonus-okrow';
+ row.style.display = 'flex';
+ row.style.alignItems = 'center';
+ row.style.gap = '8px';
+ const chk = document.createElement('img');
+ const stackTS = mission && mission.uiSkin === 'stack_tower';
+ const megaVs = mission && mission.uiSkin === 'mega_virus';
+ chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png'));
+ chk.alt = '';
+ if (stackTS || megaVs) {
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ }
+ if (tfMockSummary) {
+ chk.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ chk.alt = 'ภารกิจสำเร็จ';
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ row.appendChild(chk);
+ } else {
+ chk.width = 26;
+ chk.height = 26;
+ const sp = document.createElement('span');
+ sp.style.color = '#9ece6a';
+ sp.style.fontWeight = '700';
+ sp.textContent = 'ภารกิจสำเร็จ';
+ row.appendChild(chk);
+ row.appendChild(sp);
+ }
+ bonusEl.appendChild(row);
+ }
+ const rc = disp.rewardCard;
+ if (!mg2Summary && missionOk && rc && rc.th) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.innerHTML = '' + String(rc.th) + ' ' + String(rc.en || '') + ' ';
+ bonusEl.appendChild(pl);
+ } else if (!mg2Summary && !missionOk) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.style.borderColor = 'rgba(148, 153, 168, 0.45)';
+ pl.style.opacity = '0.95';
+ pl.innerHTML = 'ไม่ได้รับการ์ด เกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์ ';
+ bonusEl.appendChild(pl);
+ }
+ if (mission && mission.specialCardAwarded) {
+ renderSpecialCardInBonus(bonusCardHost, mission.specialCardAwarded);
+ } else if (mg2Summary && missionOk && rc && rc.th) {
+ const cardId = rc.kind === 'culprit' ? 1 : (rc.kind === 'rare' ? 5 : 3);
+ renderSpecialCardInBonus(bonusCardHost, {
+ th: rc.th,
+ en: rc.en || '',
+ imageUrl: '/Game/img/special-cards/card-' + cardId + '.png',
+ cardId: cardId,
+ rarity: rc.kind === 'rare' ? 'rare' : (rc.kind === 'culprit' ? 'legendary' : 'common'),
+ });
+ }
+ }
+ const gcmHead = document.getElementById('gcm-heading');
+ if (gcmHead) {
+ if (mission && (mission.uiSkin === 'gauntlet_crown' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) gcmHead.classList.add('sr-only');
+ else gcmHead.classList.remove('sr-only');
+ }
+ if (btn && mg2Summary) {
+ btn.style.backgroundImage = "url('" + gcmMissionMockAssetUrl(mission, 'btn-done.png') + "')";
+ } else if (btn) {
+ btn.style.removeProperty('background-image');
+ }
+ ov.classList.remove('is-hidden');
+ if (tfMockSummary) {
+ requestAnimationFrame(function () {
+ refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked);
+ });
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 200);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 550);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 1200);
+ }
+ function goLobby() {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = function () {
+ if (previewMode && editorEmbedReturn) {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'quiz_carry')) {
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ } else {
+ showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); });
+ }
+ } else {
+ goLobby();
+ }
+ };
+ }
+ }
+
+ function isLobby() { return mapData && mapData.gameType === 'lobby'; }
+ function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
+ function getLane(y) {
+ if (!mapData || !mapData.lanes) return null;
+ const lane = mapData.lanes.find(l => l.y === y);
+ return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
+ }
+ function getVehiclePositions(lane, width, timeMs) {
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
+ const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
+ const dir = lane.dir === -1 ? -1 : 1;
+ const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
+ const period = width + 2;
+ const count = Math.max(2, Math.floor(period / spacing));
+ const positions = [];
+ for (let i = 0; i < count; i++) {
+ const p = i * spacing + (timeMs * speed * dir) / 60;
+ const pNorm = ((p % period) + period) % period;
+ const vx = pNorm - 1;
+ positions.push(Math.max(-1, Math.min(width, vx)));
+ }
+ return positions;
+ }
+ function checkFroggerCollision() {
+ const fy = Math.floor(me.y);
+ const lane = getLane(fy);
+ if (!lane) return false;
+ if (lane.type === 'road') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.1) return true;
+ }
+ }
+ if (lane.type === 'water') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ let onLog = false;
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
+ }
+ if (!onLog) return true;
+ }
+ return false;
+ }
+ function respawnFrogger() {
+ const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
+ me.x = sp.x; me.y = sp.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+
+ /** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
+ function peerXYFromJoin(peer, spawnFb) {
+ const sx = Number(spawnFb && spawnFb.x);
+ const sy = Number(spawnFb && spawnFb.y);
+ const defX = Number.isFinite(sx) ? sx : 1;
+ const defY = Number.isFinite(sy) ? sy : 1;
+ if (!peer) return { x: defX, y: defY };
+ const x = Number(peer.x);
+ const y = Number(peer.y);
+ if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+ return { x: defX, y: defY };
+ }
+
+ /** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
+ function normalizeQuizCarryLayersInPlay(md) {
+ if (!md || md.gameType !== 'quiz_carry') return;
+ const w = md.width || 20, h = md.height || 15;
+ const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
+ const srcHub = md.quizCarryHubArea || [];
+ const hubRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcHub[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ hubRows.push(row);
+ }
+ md.quizCarryHubArea = hubRows;
+ const srcOpt = md.quizCarryOptionArea || [];
+ const optRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcOpt[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
+ }
+ optRows.push(row);
+ }
+ md.quizCarryOptionArea = optRows;
+ const srcCd = md.carryEmbedCountdownArea || [];
+ const cdRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcCd[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ cdRows.push(row);
+ }
+ md.carryEmbedCountdownArea = cdRows;
+ }
+
+ /** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
+ function normalizeQuizBattleDomeInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattleDomeArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattleDomeArea = grid;
+ /* 1) flood-fill หากลุ่มโดม (raw component = 1 สถานีคำถาม) */
+ const raw = Array(h).fill(0).map(() => Array(w).fill(0));
+ const compsCells = []; // compsCells[i] = [[x,y],...] ของกลุ่มที่ i (rawId = i+1)
+ let rawId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (grid[y][x] !== 1 || raw[y][x]) continue;
+ rawId++;
+ const cells = [];
+ const stack = [[x, y]];
+ raw[y][x] = rawId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ cells.push([cx, cy]);
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (grid[ny][nx] !== 1 || raw[ny][nx]) continue;
+ raw[ny][nx] = rawId;
+ stack.push([nx, ny]);
+ }
+ }
+ compsCells.push(cells);
+ }
+ }
+ /* 2) BFS ระยะทางจากจุดเกิด (เดินบนเส้นทาง+โดม) เพื่อจัดเลขโดมตามลำดับ "เดินถึงก่อน = เลขน้อยกว่า" */
+ const path = md.quizBattlePathArea;
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const bq = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ const walkable = (tx, ty) => {
+ const onPath = path && path[ty] && path[ty][tx] === 1;
+ const onDome = grid[ty] && grid[ty][tx] === 1;
+ return onPath || onDome;
+ };
+ const enq = (tx, ty, d) => {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0 || !walkable(tx, ty)) return;
+ dist[ty][tx] = d; bq.push([tx, ty, d]);
+ };
+ enq(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) enq(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) if (row[tx] === 1) enq(tx, ty, 0); }
+ }
+ let head = 0;
+ while (head < bq.length) {
+ const cur = bq[head++];
+ const cx = cur[0], cy = cur[1], cd = cur[2];
+ enq(cx + 1, cy, cd + 1); enq(cx - 1, cy, cd + 1); enq(cx, cy + 1, cd + 1); enq(cx, cy - 1, cd + 1);
+ }
+ /* 3) ระยะทางต่ำสุดของแต่ละกลุ่ม → เรียง → ให้เลข 1..N (กลุ่มที่ไปไม่ถึงไว้ท้าย) */
+ const order = compsCells.map((cells, i) => {
+ let md0 = Infinity;
+ for (const [cx, cy] of cells) { const dd = dist[cy][cx]; if (dd >= 0 && dd < md0) md0 = dd; }
+ return { idx: i, minDist: md0, firstCell: cells[0] };
+ });
+ order.sort((a, b) => (a.minDist - b.minDist) || (a.firstCell[1] - b.firstCell[1]) || (a.firstCell[0] - b.firstCell[0]));
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ order.forEach((o, rank) => { for (const [cx, cy] of compsCells[o.idx]) comp[cy][cx] = rank + 1; });
+ md.quizBattleDomeComp = comp;
+ }
+
+ function hasQuizBattleManualPathGatesPlay(md) {
+ const mg = md && md.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ function buildQuizBattlePathGateFromManualPlay(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const manual = md.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ /** ประตูบนเลน — เลนหลังโดม C ต้องตอบถูกโดม 1..C ก่อน (ยืนบนโดมได้โดยไม่ต้องตอบโดมนั้น) */
+ function buildQuizBattlePathGateRequiredPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) {
+ if (md) md.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGatesPlay(md)) {
+ buildQuizBattlePathGateFromManualPlay(md);
+ return;
+ }
+ normalizeQuizBattleDomeInPlay(md);
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const domeComp = md.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ function quizBattlePathGateRequiredAtPlay(md, px, py) {
+ const gate = md && md.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActive(md)) return 0;
+ const path = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ let maxReq = 0;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+ }
+
+ function quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) {
+ const maxReq = quizBattlePathGateRequiredAtPlay(md, px, py);
+ if (maxReq <= 0) return 0;
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solvedSet.has(c)) return c;
+ }
+ return 0;
+ }
+
+ function quizBattlePathGateAllowsPlay(md, px, py, solvedSet) {
+ if (quizBattleFootprintOnDomePlay(md, px, py)) return true;
+ return quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) === 0;
+ }
+
+ /**
+ * คำถามของโดม compId → index ใน pool — เคารพ "คำถามที่กำหนดเองต่อโดม" (quizBattleDomeQuestionArea) จาก Editor
+ * ถ้าไม่กำหนด (0) ใช้ตามลำดับเดิน (compId-1)%len เหมือนเดิม
+ */
+ function quizBattleQuestionIndexForCompPlay(compId, poolLen) {
+ if (!(poolLen > 0)) return 0;
+ let override = 0;
+ const md = mapData;
+ if (md && Array.isArray(md.quizBattleDomeComp) && Array.isArray(md.quizBattleDomeQuestionArea)) {
+ const comp = md.quizBattleDomeComp, qa = md.quizBattleDomeQuestionArea;
+ for (let y = 0; y < comp.length && !override; y++) {
+ const cr = comp[y], qr = qa[y];
+ if (!cr || !qr) continue;
+ for (let x = 0; x < cr.length; x++) {
+ if (cr[x] === compId && (qr[x] | 0) > 0) { override = qr[x] | 0; break; }
+ }
+ }
+ }
+ const idx = (override > 0 ? override - 1 : compId - 1);
+ return ((idx % poolLen) + poolLen) % poolLen;
+ }
+
+ let quizBattleGateFlashAt = 0;
+ function quizBattleGateQuestionSnippetPlay(compId) {
+ if (!(compId > 0) || !quizBattleMcqPool.length) return '';
+ const q = quizBattleMcqPool[quizBattleQuestionIndexForCompPlay(compId, quizBattleMcqPool.length)];
+ if (!q || !q.text) return '';
+ const t = String(q.text).trim();
+ return t.length > 42 ? t.slice(0, 42) + '…' : t;
+ }
+
+ function quizBattleFlashGateBlocked(md, px, py, solvedSet) {
+ const need = quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet);
+ if (!need) return;
+ const now = performance.now();
+ if (now - quizBattleGateFlashAt < 1400) return;
+ quizBattleGateFlashAt = now;
+ const snip = quizBattleGateQuestionSnippetPlay(need);
+ flashQuizBattleFeedback(
+ snip
+ ? ('ตอบข้อ ' + need + ' ก่อนผ่าน: 「' + snip + '」')
+ : ('ตอบคำถามข้อ ' + need + ' ก่อนผ่านทางนี้'),
+ false
+ );
+ }
+
+ /** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */
+ function normalizeQuizBattlePathInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattlePathArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattlePathArea = grid;
+ }
+
+ function quizBattlePathModeActive(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false;
+ const g = md.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+ }
+
+ /** ทุกช่องที่สไปรต์กินพื้นที่ต้องอยู่บนเลน — กันหลุดไปพื้นเปิด (path=0) หรือทับกำแพง (objects=1) */
+ function quizBattleFootprintFullyOnPath(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ const g = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) — โซนรอก่อนลงเลน */
+ /** footprint แตะโซนจุดจบ (quizBattleFinishArea) ไหม — ใช้อนุญาตให้ยืน/เด้งไปจุดจบได้โดยไม่ถูกสแน็ปกลับเลน */
+ function quizBattleFootprintTouchesFinishPlay(md, px, py) {
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (fa[ty] && fa[ty][tx] === 1) return true;
+ }
+ const cx = Math.floor(px), cy = Math.floor(py);
+ return !!(fa[cy] && fa[cy][cx] === 1);
+ }
+
+ function quizBattlePositionAllowedPlay(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPath(md, px, py)) return true;
+ if (quizBattleFootprintTouchesFinishPlay(md, px, py)) return true; /* โซนจุดจบ — ยืนได้ */
+ const sa = md.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** สปอว์นในพื้นที่ฟ้า — เช็คแค่ไม่ทับกำแพง ไม่บังคับอยู่บนเลน */
+ function quizBattleSpawnWallClearPlay(md, nx, ny) {
+ if (!md || !md.objects) return false;
+ if (typeof nx !== 'number' || typeof ny !== 'number' || !Number.isFinite(nx) || !Number.isFinite(ny)) return false;
+ const w = md.width || 20, h = md.height || 15;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const k of quizTilesWallCollisionFootprintPlay(nx, ny)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** เดินทีละย่อย — เลนบาง 1 ช่อง + กล่องสไปรต์ใหญ่กว่าเท้า ยังเดินได้โดยไม่หลุดเลน */
+ function quizBattleTryStepMovePlay(fx, fy, tx, ty, canWalkFn) {
+ const SUB = 8;
+ let x = fx;
+ let y = fy;
+ for (let i = 1; i <= SUB; i++) {
+ const t = i / SUB;
+ const nx = fx + (tx - fx) * t;
+ const ny = fy + (ty - fy) * t;
+ if (canWalkFn(nx, ny, x, y)) {
+ x = nx;
+ y = ny;
+ }
+ }
+ return { x, y };
+ }
+
+ /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */
+ function quizBattleSnapTargetValidPlay(x, y) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ for (const k of quizTilesWallCollisionFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (quizBattlePathModeActive(mapData) && !quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ return true;
+ }
+
+ function snapPositionOntoQuizBattlePathIfNeeded(px, py) {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const g = mapData.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ /* footprint ใหญ่กว่าเลน: ลองจุดกลางช่อง — ต้องผ่านกล่องสไปรต์+path เหมือนก้อนบน (เดิมเช็คแค่ไม่ทับกำแพง → snap หลุดเลน) */
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = mapData.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+ }
+
+ /** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */
+ function enforceQuizBattleLaneOnMePlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ if (quizBattleSnapTargetValidPlay(me.x, me.y)) return;
+ const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = snapped.x;
+ me.y = snapped.y;
+ }
+
+ /** สแน็ป peer / บอทหลัง lerp — กันตัวละครล้ำกำแพงหรือหลุดเลนระหว่างเฟรม */
+ function enforceQuizBattleLaneOnPeersPlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ others.forEach((o) => {
+ if (!o) return;
+ if (quizBattleSnapTargetValidPlay(o.x, o.y)) return;
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = sn.x;
+ o.y = sn.y;
+ o.tx = sn.x;
+ o.ty = sn.y;
+ });
+ }
+
+ function hideQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ quizBattleChoiceLocked = false;
+ }
+
+ function flashQuizBattleFeedback(text, ok) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.textContent = text || '';
+ el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad');
+ el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad');
+ if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT);
+ window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800);
+ }
+
+ function getQuizBattleDomeCompIdsUnderFootprint(px, py) {
+ if (!mapData || !isQuizBattle()) return [];
+ const comp = mapData.quizBattleDomeComp;
+ const grid = mapData.quizBattleDomeArea;
+ if (!comp || !grid) return [];
+ const ids = [];
+ const seen = Object.create(null);
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const cid = comp[ty] && comp[ty][tx];
+ if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) {
+ seen[cid] = 1;
+ ids.push(cid);
+ }
+ }
+ return ids;
+ }
+
+ function getPrimaryQuizBattleDomeCompAt(px, py) {
+ const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py);
+ if (!arr.length) return null;
+ return Math.min.apply(null, arr);
+ }
+
+ function quizBattleFootprintOnDomePlay(md, px, py) {
+ const dome = md && md.quizBattleDomeArea;
+ if (!dome) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+ }
+
+ /** โดมใต้เท้า หรือช่องติดกัน (8 ทิศ) — ให้กด E / เด้งคำถามได้แม้ยืนข้างสถานี */
+ function getNearestQuizBattleDomeCompAt(px, py) {
+ const direct = getPrimaryQuizBattleDomeCompAt(px, py);
+ if (direct != null) return direct;
+ if (!mapData || !isQuizBattle()) return null;
+ const dome = mapData.quizBattleDomeArea;
+ const comp = mapData.quizBattleDomeComp;
+ if (!dome) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let best = null;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!dome[ny] || dome[ny][nx] !== 1) continue;
+ const cid = comp && comp[ny] && comp[ny][nx] ? comp[ny][nx] : 1;
+ if (best == null || cid < best) best = cid;
+ }
+ }
+ return best;
+ }
+
+ function showQuizBattleMcqModal(q) {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ const textEl = document.getElementById('quiz-battle-mcq-text');
+ if (!ov || !textEl || !q) return;
+ const titleEl = document.getElementById('quiz-battle-mcq-title');
+ if (titleEl) titleEl.textContent = 'QUESTION #' + (quizBattleModalCompId || '');
+ textEl.textContent = q.text || '';
+ const labels = ['A', 'B', 'C'];
+ for (let i = 0; i < 3; i++) {
+ const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!btn) continue;
+ const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : '';
+ const letterEl = btn.querySelector('.qbc-letter');
+ const txtEl = btn.querySelector('.qbc-text');
+ const markEl = btn.querySelector('.qbc-mark');
+ if (letterEl) letterEl.textContent = labels[i];
+ if (txtEl) txtEl.textContent = ch;
+ if (markEl) { markEl.removeAttribute('src'); markEl.alt = ''; }
+ btn.classList.remove('is-correct', 'is-wrong', 'is-locked');
+ btn.disabled = false;
+ }
+ quizBattleChoiceLocked = false;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+
+ function trySubmitQuizBattleChoice(idx) {
+ if (quizBattleChoiceLocked) return;
+ if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return;
+ const compId = quizBattleModalCompId;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false);
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ const qIdx = quizBattleQuestionIndexForCompPlay(compId, pool.length);
+ const q = pool[qIdx];
+ if (!q) return;
+ const ok = Number(q.correctIndex) === Number(idx);
+ /* ตอบครั้งเดียวจบ — ผิดก็ "ผ่าน" และจำว่าผิด (ไม่ให้ตอบซ้ำ) · log ใช้ทำ MY RESULT (O/X) + accuracy */
+ quizBattleAnswerLog.set(compId, {
+ qIndex: qIdx,
+ firstTry: ok,
+ correct: ok,
+ submittedIdx: idx,
+ q: q,
+ });
+ quizBattlePassedComps.add(compId); /* ผ่านโดมนี้แล้ว (ถูกหรือผิดก็ผ่าน — ปลดล็อกเส้นทาง) */
+ /* โชว์ผลถูก/ผิดบนตัวเลือกทันที (q-correct.png / q-wrong.png) แล้วล็อกกันกดซ้ำ */
+ quizBattleChoiceLocked = true;
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.querySelectorAll('.quiz-battle-choice').forEach((b) => { b.classList.add('is-locked'); b.disabled = true; });
+ const setMark = (i, kind) => {
+ const b = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!b) return;
+ b.classList.add(kind === 'correct' ? 'is-correct' : 'is-wrong');
+ const m = b.querySelector('.qbc-mark');
+ if (m) m.src = BASE + '/img/qb-ui/' + (kind === 'correct' ? 'q-correct.png' : 'q-wrong.png');
+ };
+ if (ok) {
+ setMark(idx, 'correct');
+ } else {
+ setMark(idx, 'wrong');
+ setMark(Number(q.correctIndex), 'correct'); /* เฉลยคำตอบที่ถูก */
+ }
+ }
+ if (ok) {
+ quizBattleAnsweredComps.add(compId);
+ if (typeof socket !== 'undefined' && socket && socket.connected) {
+ try { socket.emit('quizbattle-solve', { compId: compId }, function () {}); } catch (e) { /* ignore */ }
+ }
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ submitQuizBattleLeaderboardScore();
+ flashQuizBattleFeedback('ถูกต้อง · +1 · ทางไปข้างหน้าเปิดแล้ว', true);
+ } else {
+ flashQuizBattleFeedback('ตอบผิด — ผ่านได้เลย (บันทึกว่าผิด) ไม่ต้องตอบซ้ำ', false);
+ }
+ saveQuizBattleProgress();
+ /* ปิด popup แล้วเช็คจบ (ทั้งถูกและผิด) — ผิดหน่วงนานขึ้นให้เห็นเฉลยก่อน */
+ setTimeout(() => {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ const totalComps = quizBattleTotalCompsPlay();
+ if (!quizBattleCompletedShown && totalComps > 0 && quizBattlePassedComps.size >= totalComps) {
+ quizBattleCompletedShown = true;
+ setTimeout(openQuizBattleCompleted, 280);
+ }
+ }, ok ? 950 : 1300);
+ }
+
+ /**
+ * ชื่อผู้เล่นสำหรับ leaderboard/ranking — ผูกกับชื่อที่ตั้งใน Main-Lobby
+ * (localStorage 'roomLobbyDisplayName' → 'playerName' ผ่าน readStoredDisplayName / jdReadDisplayName)
+ * ก่อน fallback ไป me.nickname / ?nick= เพื่อให้ตรงกับโปรไฟล์เดียวกันทุกหน้า
+ */
+ function quizBattlePlayerDisplayName() {
+ let stored = '';
+ try {
+ if (typeof window.jdReadDisplayName === 'function') {
+ const v = String(window.jdReadDisplayName() || '').trim();
+ if (v && v !== 'MONE') stored = v; /* 'MONE' = ค่า default เมื่อยังไม่ตั้งชื่อ → ไม่ถือว่าตั้งแล้ว */
+ }
+ } catch (e) { /* ignore */ }
+ if (!stored) stored = readStoredDisplayName();
+ const name = stored || (me && me.nickname) || nick || 'ผู้เล่น';
+ return String(name || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น';
+ }
+
+ /** ส่งคะแนน (จำนวนตอบถูก) ขึ้น global leaderboard ของห้อง/หมวด */
+ function submitQuizBattleLeaderboardScore() {
+ if (!isQuizBattle() || !socket || !socket.connected) return;
+ const roomNo = quizBattleRoomNoPlay();
+ if (roomNo <= 0) return;
+ const score = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ try {
+ socket.emit('qb-leaderboard-submit', {
+ roomId: 'qbroom' + roomNo,
+ name: quizBattlePlayerDisplayName(),
+ score: score,
+ characterId: me.characterId || getPlayCharacterId() || '',
+ });
+ } catch (e) { /* ignore */ }
+ quizBattleScoreSubmittedAt = Date.now();
+ }
+
+ /* ===== Quiz Battle: progress persistence (resume ต่อจากเดิม) ===== */
+ function quizBattleProgressKey() {
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ return 'qbProgress:' + roomId + ':' + quizBattlePlayerDisplayName();
+ }
+ function saveQuizBattleProgress() {
+ if (!isQuizBattle()) return;
+ try {
+ const passed = [];
+ quizBattleAnswerLog.forEach((v, c) => {
+ passed.push({ c: c, qi: v.qIndex, ft: v.firstTry ? 1 : 0, ok: v.correct ? 1 : 0, idx: v.submittedIdx });
+ });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ const data = {
+ passed: passed,
+ pos: (me && Number.isFinite(me.x) && Number.isFinite(me.y)) ? { x: me.x, y: me.y } : null,
+ elapsed: Math.max(0, Math.round(elapsed)),
+ ts: Date.now(),
+ };
+ localStorage.setItem(quizBattleProgressKey(), JSON.stringify(data));
+ } catch (e) { /* ignore */ }
+ }
+ function loadQuizBattleProgress() {
+ try {
+ const raw = localStorage.getItem(quizBattleProgressKey());
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) { return null; }
+ }
+ /** คืนสถานะที่เคยเล่น (โดมที่ผ่าน/คำตอบ/เวลา) + ตำแหน่งล่าสุด — เรียกใน setupPlayQuizBattleUi */
+ function restoreQuizBattleProgress() {
+ const data = loadQuizBattleProgress();
+ if (!data || !Array.isArray(data.passed)) return;
+ data.passed.forEach((p) => {
+ if (!p || !(p.c > 0)) return;
+ quizBattleAnswerLog.set(p.c, {
+ qIndex: (p.qi != null ? p.qi : 0),
+ firstTry: !!p.ft,
+ correct: !!p.ok,
+ submittedIdx: p.idx,
+ q: null, /* q ผูกใหม่จาก pool ตอนเปิด MY RESULT */
+ });
+ quizBattlePassedComps.add(p.c);
+ if (p.ok) quizBattleAnsweredComps.add(p.c);
+ });
+ /* เวลาเดินต่อ (offset session start ด้วยเวลาที่เคยใช้) */
+ if (data.elapsed > 0) {
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleSessionStartMs = nowMs - data.elapsed;
+ }
+ /* sync score HUD + leaderboard ให้ตรงกับที่เคยทำ */
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = quizBattleAnsweredComps.size;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ /* ย้ายตัวไปจุดเดิม (ถ้ายังเดินได้) แล้วบอกเซิร์ฟเวอร์ */
+ if (data.pos && Number.isFinite(data.pos.x) && Number.isFinite(data.pos.y)) {
+ const px = data.pos.x, py = data.pos.y;
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle() || !me) return;
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — อย่าเด้งกลับจุดเดิม */
+ if (typeof canWalkLikeLobby === 'function' && !canWalkLikeLobby(px, py)) return;
+ me.x = px; me.y = py;
+ if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ draw();
+ } catch (e) { /* ignore */ }
+ }, 350);
+ }
+ /* เข้ามาแล้วตอบครบทุกข้ออยู่แล้ว (resume) → ไปจุดจบเลย ไม่ต้องตอบใหม่ */
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle()) return;
+ const total = quizBattleTotalCompsPlay();
+ if (total > 0 && quizBattlePassedComps.size >= total) {
+ quizBattleCompletedShown = true;
+ moveToQuizBattleFinishPlay();
+ }
+ } catch (e) { /* ignore */ }
+ }, 600);
+ }
+
+ /* ===== Quiz Battle: MY RESULT + RANKING popups (Stage 2) ===== */
+ function qbEscHtml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function qbFmtDuration(ms) {
+ const s = Math.max(0, Math.round((ms || 0) / 1000));
+ const m = Math.floor(s / 60), ss = s % 60;
+ if (m >= 60) { const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; }
+ if (m > 0) return m + 'm ' + ss + 's';
+ return ss + ' s';
+ }
+ /** จำนวนโดม (สถานีคำถาม) ทั้งหมดของแมป = ค่า comp สูงสุดใน quizBattleDomeComp */
+ function quizBattleTotalCompsPlay() {
+ const md = mapData;
+ if (md && md.quizBattleDomeComp && Array.isArray(md.quizBattleDomeComp)) {
+ let mx = 0;
+ const dc = md.quizBattleDomeComp;
+ for (let y = 0; y < dc.length; y++) {
+ const row = dc[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { const v = row[x] | 0; if (v > mx) mx = v; }
+ }
+ if (mx > 0) return mx;
+ }
+ return quizBattleMcqPool.length || 0;
+ }
+ function qbSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; }
+ function qbAvatarSrcForCharacter(cid) {
+ if (!cid) return '';
+ try { ensurePlayLobbyIdleAvatarImage(cid); } catch (e) { /* ignore */ }
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) return src;
+ const im = getCharacterImg(cid, 'down');
+ return (im && im.src) ? im.src : '';
+ }
+
+ function openQuizBattleMyResult() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-myresult-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ const passed = quizBattlePassedComps ? quizBattlePassedComps.size : 0;
+ let firstCorrect = 0, attempted = 0;
+ quizBattleAnswerLog.forEach((v) => { attempted++; if (v.firstTry) firstCorrect++; });
+ const accPct = attempted ? Math.round((firstCorrect / attempted) * 100) : 0;
+ const progPct = total ? Math.round((passed / total) * 100) : 0;
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-mr-accuracy', accPct + '%');
+ qbSetText('qb-mr-accuracy-sub', '(' + firstCorrect + '/' + attempted + ')');
+ qbSetText('qb-mr-progress', progPct + '%');
+ qbSetText('qb-mr-progress-sub', '(' + passed + '/' + total + ')');
+ qbSetText('qb-mr-time', qbFmtDuration(elapsed));
+ const grid = document.getElementById('qb-mr-grid');
+ if (grid) {
+ const cells = Math.max(total, attempted, 0);
+ let html = '';
+ for (let c = 1; c <= cells; c++) {
+ const log = quizBattleAnswerLog.get(c);
+ let cls = 'qb-mr-cell', mark = '';
+ if (!log) { cls += ' is-unanswered'; }
+ else if (log.correct) { cls += ' is-correct'; mark = ' '; }
+ else { cls += ' is-wrong'; mark = ' '; }
+ html += '' + c + ' ' + mark + '
';
+ }
+ grid.innerHTML = html;
+ }
+ const det = document.getElementById('qb-mr-detail');
+ if (det) {
+ const entries = Array.from(quizBattleAnswerLog.entries()).sort((a, b) => a[0] - b[0]);
+ if (!entries.length) {
+ det.innerHTML = 'ยังไม่ได้ตอบคำถาม — เดินเข้าโดมแล้วกด E เพื่อเริ่ม
';
+ } else {
+ const letters = ['A', 'B', 'C'];
+ let html = '';
+ entries.forEach(function (pair) {
+ const cid = pair[0], log = pair[1];
+ const q = log.q || quizBattleMcqPool[log.qIndex] || {};
+ const choices = Array.isArray(q.choices) ? q.choices : [];
+ const markImg = log.correct ? 'q-correct.png' : 'q-wrong.png';
+ html += '';
+ html += '
ข้อ ' + cid + '
';
+ html += '
' + qbEscHtml(q.text || '') + '
';
+ choices.forEach(function (ch, i) {
+ let ocls = 'qb-mr-q-opt';
+ if (i === Number(q.correctIndex)) ocls += ' is-correct';
+ else if (i === Number(log.submittedIdx) && !log.correct) ocls += ' is-chosen-wrong';
+ html += '
' + letters[i] + '. ' + qbEscHtml(ch) + '
';
+ });
+ html += '
';
+ });
+ det.innerHTML = html;
+ }
+ det.scrollTop = 0;
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ function renderQuizBattleRanking(data) {
+ const top = (data && Array.isArray(data.top)) ? data.top : [];
+ const meRow = data && data.me ? data.me : null;
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) {
+ const order = [1, 0, 2]; /* แสดง 2-1-3 */
+ let html = '';
+ order.forEach(function (idx) {
+ const e = top[idx];
+ if (!e) return;
+ const rank = idx + 1;
+ const av = qbAvatarSrcForCharacter(e.characterId);
+ html += '';
+ html += '
' + (av ? '
' : '') + '
';
+ html += '
' + rank + '
';
+ html += '
' + qbEscHtml(e.name || '-') + '
';
+ html += '
' + (e.score | 0) + '
';
+ html += '
';
+ });
+ podium.innerHTML = html || 'ยังไม่มีคะแนน
';
+ }
+ const list = document.getElementById('qb-rank-list');
+ if (list) {
+ let html = '';
+ for (let i = 3; i < top.length && i < 50; i++) {
+ const e = top[i];
+ html += '' + (e.rank || (i + 1)) + ' '
+ + '' + qbEscHtml(e.name || '-') + ' '
+ + '' + (e.score | 0) + '
';
+ }
+ list.innerHTML = html;
+ }
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) {
+ if (meRow && (meRow.rank || 0) > 0) {
+ meBox.innerHTML = '' + (meRow.rank || '-') + ' '
+ + '' + qbEscHtml(meRow.name || 'คุณ') + ' (คุณ) '
+ + '' + (meRow.score | 0) + '
';
+ } else {
+ meBox.innerHTML = '';
+ }
+ }
+ }
+
+ function openQuizBattleRanking() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-ranking-popup');
+ if (!pop) return;
+ /* ส่งคะแนนล่าสุดก่อน เพื่อให้อันดับตัวเองเป็นปัจจุบัน */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ const list = document.getElementById('qb-rank-list');
+ if (list) list.innerHTML = 'กำลังโหลดอันดับ…
';
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) podium.innerHTML = '';
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) meBox.innerHTML = '';
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ renderQuizBattleRanking(resp || {});
+ });
+ } catch (e) {
+ if (list) list.innerHTML = 'โหลดอันดับไม่สำเร็จ
';
+ }
+ } else if (list) {
+ list.innerHTML = 'ยังไม่ได้เชื่อมต่อเซิร์ฟเวอร์
';
+ }
+ }
+
+ function closeQuizBattlePopup(which) {
+ let id = 'qb-myresult-popup';
+ if (which === 'ranking') id = 'qb-ranking-popup';
+ else if (which === 'completed') id = 'qb-completed-popup';
+ const pop = document.getElementById(id);
+ if (!pop) return;
+ pop.classList.add('is-hidden');
+ pop.setAttribute('aria-hidden', 'true');
+ }
+
+ /** popup "Completed" (popup-result.png) — สรุป Correct / Time Taken / My Rank เมื่อตอบครบทุกโดม */
+ /** สุ่มช่องในโซนจุดจบ (quizBattleFinishArea) เป็นพิกัด tile — ไม่มี = null (ให้ผู้เล่นกระจายตัว ไม่กองจุดเดียว) */
+ function quizBattleFinishRandomCellPlay() {
+ const md = mapData;
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return null;
+ const cells = [];
+ for (let y = 0; y < fa.length; y++) {
+ const row = fa[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { if (row[x] === 1) cells.push([x, y]); }
+ }
+ if (!cells.length) return null;
+ const pick = cells[Math.floor(Math.random() * cells.length)];
+ /* สุ่มตำแหน่งย่อยในช่องด้วย เพื่อไม่ให้ซ้อนกันเป๊ะแม้สุ่มได้ช่องเดียวกัน */
+ const jitter = () => 0.25 + Math.random() * 0.5;
+ return { x: pick[0] + jitter(), y: pick[1] + jitter() };
+ }
+ /** ตอบครบทุกข้อ → ย้ายผู้เล่นไปจุดจบที่ตั้งไว้ใน Editor (สุ่มช่อง) */
+ function moveToQuizBattleFinishPlay() {
+ const c = quizBattleFinishRandomCellPlay();
+ if (!c || !me) return;
+ quizBattleFinishLock = true; /* ล็อก: หลังจากนี้ห้าม echo/restore เด้งกลับ */
+ me.x = c.x; me.y = c.y;
+ playPath = [];
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+
+ function openQuizBattleCompleted() {
+ if (!isQuizBattle()) return;
+ moveToQuizBattleFinishPlay(); /* ย้ายไปจุดจบที่ตั้งไว้ (ถ้ามี) */
+ const pop = document.getElementById('qb-completed-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ let firstCorrect = 0;
+ quizBattleAnswerLog.forEach((v) => { if (v.firstTry) firstCorrect++; });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-cp-correct', firstCorrect + ' / ' + total);
+ qbSetText('qb-cp-time', qbFmtDuration(elapsed));
+ qbSetText('qb-cp-rank', '—');
+ /* ส่งคะแนนล่าสุด แล้วดึงอันดับมาแสดง My Rank */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ const rank = resp && resp.me && resp.me.rank ? ('#' + resp.me.rank) : '—';
+ qbSetText('qb-cp-rank', rank);
+ });
+ } catch (e) { /* ignore */ }
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ /** หา comp โดมที่ตัวละครเหยียบอยู่ — เช็คหลายจุด + ช่องติดโดม ให้เด้งง่ายเมื่อถึงสถานี */
+ function quizBattleDomeAtMe() {
+ if (!isQuizBattle()) return null;
+ const pts = [[me.x, me.y], [me.x, me.y + 0.45], [me.x, me.y - 0.25], [me.x + 0.35, me.y], [me.x - 0.35, me.y]];
+ for (let i = 0; i < pts.length; i++) {
+ const c = getNearestQuizBattleDomeCompAt(pts[i][0], pts[i][1]);
+ if (c != null) return c;
+ }
+ return getNearestQuizBattleDomeCompAt(me.x, me.y);
+ }
+
+ /** ZEP-style: เหยียบสถานี (โดม) ที่ยังไม่ตอบ → เปิดคำถามอัตโนมัติ (ครั้งเดียวต่อการเข้า · เดินออกแล้วกลับมาใหม่ถึงเด้งอีก) */
+ function maybeAutoOpenQuizBattle() {
+ if (!isQuizBattle() || myId == null) return;
+ const compNow = quizBattleDomeAtMe();
+ 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 (quizBattlePassedComps.has(compNow)) return;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — บันทึกใน Admin → Quiz Battle แล้วรีเฟรช', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compNow, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compNow;
+ showQuizBattleMcqModal(q);
+ }
+
+ function qbRoundRect(ctx, x, y, w, h, r) {
+ r = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+ /** หีบบนสถานีคำถาม — ตอบถูก=เขียว+ติ๊ก / ตอบผิด(ผ่านแล้ว)=แดง+กากบาท / ยังไม่ตอบ=ล็อก(น้ำตาล+กุญแจ) + เลขสถานี */
+ function drawQuizBattleChest(ctx, cx, cy, size, done, num, wrong) {
+ const w = size * 0.84, h = size * 0.64;
+ const x0 = cx - w / 2, y0 = cy - h / 2;
+ ctx.save();
+ ctx.shadowColor = done ? (wrong ? 'rgba(248,113,128,0.95)' : 'rgba(52,211,153,0.95)') : 'rgba(255,200,70,0.95)';
+ ctx.shadowBlur = Math.max(4, size * 0.3);
+ ctx.fillStyle = done ? (wrong ? '#8a3340' : '#3f7d57') : '#8a5a2b';
+ qbRoundRect(ctx, x0, y0 + h * 0.34, w, h * 0.66, size * 0.08); ctx.fill();
+ ctx.fillStyle = done ? (wrong ? '#b3455a' : '#57a875') : '#a86f33';
+ qbRoundRect(ctx, x0, y0, w, h * 0.44, size * 0.1); ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = done ? (wrong ? '#ffd6dc' : '#d6f5e3') : '#f1c66a';
+ ctx.fillRect(x0, y0 + h * 0.30, w, h * 0.10);
+ ctx.fillRect(cx - size * 0.045, y0, size * 0.09, h);
+ if (done && wrong) {
+ ctx.strokeStyle = '#fff0f2'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.12, cy - size * 0.10); ctx.lineTo(cx + size * 0.12, cy + size * 0.12);
+ ctx.moveTo(cx + size * 0.12, cy - size * 0.10); ctx.lineTo(cx - size * 0.12, cy + size * 0.12);
+ ctx.stroke();
+ } else if (done) {
+ ctx.strokeStyle = '#eafff3'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.13, cy + size * 0.04);
+ ctx.lineTo(cx - size * 0.02, cy + size * 0.15);
+ ctx.lineTo(cx + size * 0.17, cy - size * 0.12);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = '#f1c66a'; ctx.lineWidth = Math.max(2, size * 0.05);
+ ctx.beginPath(); ctx.arc(cx, cy + h * 0.04, size * 0.085, Math.PI, 0); ctx.stroke();
+ ctx.fillStyle = '#f1c66a';
+ qbRoundRect(ctx, cx - size * 0.085, cy + h * 0.04, size * 0.17, size * 0.13, size * 0.03); ctx.fill();
+ }
+ if (num) {
+ ctx.fillStyle = 'rgba(8,12,28,0.82)';
+ ctx.beginPath(); ctx.arc(cx, y0 - size * 0.14, size * 0.17, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold ' + Math.max(9, size * 0.23) + 'px Kanit, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(String(num), cx, y0 - size * 0.14);
+ ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ }
+ ctx.restore();
+ }
+
+ function tryOpenQuizBattleFromKey() {
+ if (!isQuizBattle() || !mapData || myId == null) return;
+ const compId = quizBattleDomeAtMe();
+ if (compId == null) {
+ flashQuizBattleFeedback('เข้าใกล้สถานีคำถาม (หีบ) แล้วกด E', false);
+ return;
+ }
+ if (quizBattlePassedComps.has(compId)) {
+ flashQuizBattleFeedback('โดมนี้ผ่านแล้ว', false);
+ return;
+ }
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compId, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compId;
+ showQuizBattleMcqModal(q);
+ }
+
+ function pushSanitizedBattleMcqFromPayload(s) {
+ if (!s || !Array.isArray(s.battleQuizMcq)) return 0;
+ /* กรองเฉพาะคลังคำถามของหัวข้อห้องนี้ (mapData.quizBattleCategoryId เช่น topic1..topic10) — ไม่ระบุ = ใช้ทั้งหมด */
+ const roomCat = (mapData && mapData.quizBattleCategoryId) ? String(mapData.quizBattleCategoryId).trim() : '';
+ let n = 0;
+ for (let i = 0; i < s.battleQuizMcq.length; i++) {
+ const raw = s.battleQuizMcq[i];
+ if (!raw || !String(raw.text || '').trim()) continue;
+ if (roomCat && String(raw.categoryId || '').trim() !== roomCat) continue;
+ const ch = Array.isArray(raw.choices) ? raw.choices : [];
+ if (ch.length !== 3) continue;
+ const a = String(ch[0] || '').trim();
+ const b = String(ch[1] || '').trim();
+ const c = String(ch[2] || '').trim();
+ if (!a || !b || !c) continue;
+ let ci = Number(raw.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ quizBattleMcqPool.push({
+ text: String(raw.text).trim(),
+ choices: [a, b, c],
+ correctIndex: ci,
+ });
+ n++;
+ }
+ return n;
+ }
+
+ /**
+ * โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node)
+ */
+ async function loadQuizBattleMcqPool() {
+ quizBattleMcqPool = [];
+ const bust = '_=' + Date.now();
+ const tryUrls = [
+ BASE + '/api/quiz-settings?' + bust,
+ BASE + '/api-quiz-battle-mcq.php?' + bust,
+ ];
+ for (let u = 0; u < tryUrls.length; u++) {
+ try {
+ const r = await fetch(tryUrls[u], { cache: 'no-store' });
+ if (!r.ok) continue;
+ const s = await r.json();
+ if (pushSanitizedBattleMcqFromPayload(s) > 0) break;
+ } catch (e) { /* next url */ }
+ }
+ }
+
+ function resetQuizBattlePlayState() {
+ quizBattleMcqPool = [];
+ quizBattleAnsweredComps = new Set();
+ quizBattlePassedComps = new Set();
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+
+ function setupPlayQuizBattleUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ /* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */
+ if (ov) {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'Quiz Battle';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = quizBattlePathModeActive(mapData)
+ ? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง'
+ : 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม';
+ }
+ quizBattleAnsweredComps = new Set();
+ quizBattleAnswerLog = new Map();
+ quizBattlePassedComps = new Set();
+ quizBattleUserZoomMul = 1;
+ quizBattleSessionStartMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleModalCompId = null;
+ quizBattleMcqPool = [];
+ quizBattleCompletedShown = false;
+ quizBattleFinishLock = false;
+ hideQuizBattleMcqModal();
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ /* คืนสถานะที่เคยเล่น (โดมที่ผ่าน/ตำแหน่งล่าสุด) — เล่นต่อจากเดิมไม่ต้องตอบซ้ำ */
+ restoreQuizBattleProgress();
+ loadQuizBattleMcqPool().then(() => {
+ const q2 = document.getElementById('quiz-game-question');
+ if (!q2) return;
+ if (!quizBattleMcqPool.length) {
+ q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)';
+ } else {
+ const q0 = quizBattleMcqPool[0];
+ const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : '';
+ const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : '';
+ q2.textContent = snippet
+ ? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C')
+ : ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C');
+ }
+ const ov2 = document.getElementById('quiz-game-overlay');
+ if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden');
+ });
+ }
+
+ function normalizeJumpSurvivePlatformAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformArea = rows;
+ }
+
+ function normalizeJumpSurvivePlatformVariantAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea || [];
+ const src = md.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformVariantArea = rows;
+ }
+
+ function jumpSurvivePlatformVariantIndexAtPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa || !pa[ty] || pa[ty][tx] !== 1) return 0;
+ const va = md.jumpSurvivePlatformVariantArea;
+ let v = va && va[ty] ? Math.floor(Number(va[ty][tx])) : 1;
+ if (!Number.isFinite(v) || v < 1) v = 1;
+ if (v > 3) v = 3;
+ return v;
+ }
+
+ function normalizeJumpSurviveHazardAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurviveHazardArea = rows;
+ }
+
+ /** โซนตาย jump_survive — กริดคงที่ในโลก (ไม่เลื่อนกับแพลตฟอร์ม) · ช่องที่มีแพลตฟอร์มอยู่ด้วย = ไม่นับ hazard (ยืนบนแพลตฟอร์มชนะ) */
+ function jumpSurviveOverlapsHazardArea(md, left, top, right, bottom) {
+ const ha = md && md.jumpSurviveHazardArea;
+ if (!ha) return false;
+ const pa = md && md.jumpSurvivePlatformArea;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const y0 = Math.max(0, Math.floor(top / ts));
+ const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts));
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!ha[ty] || ha[ty][tx] !== 1) continue;
+ if (pa && pa[ty] && pa[ty][tx] === 1) continue;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function normalizeShooterSpawnSlotsInPlay(md) {
+ if (!md || (md.gameType !== 'space_shooter' && md.gameType !== 'jump_survive')) return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.shooterSpawnSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.shooterSpawnSlots = rows;
+ }
+
+ function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.shooterSpawnSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.shooterSpawnSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function buildSpaceShooterParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function applySpaceShooterSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const n = refs.length || 1;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = (idx % 6) + 1;
+ const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ cx = ((idx + 1) / (n + 1)) * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
+ }
+ cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
+ cy = clampSpaceShooterWorldCy(cy, mh);
+ ent.spaceShooterCx = cx;
+ ent.spaceShooterCy = cy;
+ ent.spaceShooterSlot = slot;
+ if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
+ ent.x = cx / ts - cw * 0.5;
+ ent.y = cy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'up';
+ });
+ }
+
+ function spaceShooterTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.spaceShooterTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playSpaceShooterMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 90;
+ }
+
+ function spaceShooterRemainingSecPlay() {
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null;
+ const lim = spaceShooterTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function endSpaceShooterTimeUp() {
+ if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay()) {
+ endSpaceShooterMissionRound('time_up');
+ return;
+ }
+ spaceShooterGameEnded = true;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
+ msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function syncSpaceShooterFootFromShipCenter(ent) {
+ if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.spaceShooterCx / ts - cw * 0.5;
+ ent.y = ent.spaceShooterCy / ts - ch * 0.92;
+ }
+
+ /** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
+ function clampSpaceShooterWorldCy(cy, mhPx) {
+ if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
+ const edge = 20;
+ const lo = mhPx * 0.5;
+ const hi = Math.max(lo + 4, mhPx - edge);
+ return Math.max(lo, Math.min(hi, cy));
+ }
+
+ const SPACE_SHOOTER_ASTEROID_INTERVAL_MIN = 200;
+ /** ms ระหว่างเกิดอุกาบาต — แมปทับถ้าตั้ง ≥200 */
+ function spaceShooterAsteroidSpawnIntervalMsPlay() {
+ const mapI = Number(mapData && mapData.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(mapI) && mapI >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) {
+ return Math.min(10000, Math.floor(mapI));
+ }
+ const g = Number(playSpaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(g) && g >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) return Math.min(10000, Math.floor(g));
+ return 1040;
+ }
+
+ function stepSpaceShooterPreviewBots(dt) {
+ if (spaceShooterGameEnded || !playBotsEnabled() || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const ts = tileSize;
+ const mw = (mapData.width || 20) * ts;
+ const mh = (mapData.height || 15) * ts;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.spaceShooterCx == null || o.spaceShooterEliminated) return;
+ let nearest = null, nd = 1e9;
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ if (!a) continue;
+ const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
+ if (d < nd) { nd = d; nearest = a; }
+ }
+ let vx = 0;
+ if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
+ else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
+ let vy = 0;
+ if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
+ vy = nearest.y > o.spaceShooterCy ? 1 : -1;
+ } else if (!nearest) {
+ vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
+ }
+ const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ o.spaceShooterCx = Math.max(padX, Math.min(mw - padX, o.spaceShooterCx + vx * spd * dt));
+ o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
+ syncSpaceShooterFootFromShipCenter(o);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
+ o.spaceShooterBotFireCd -= dt;
+ if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
+ o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
+ spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
+ }
+ });
+ }
+
+ function spaceShooterTickFrame() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const now = performance.now();
+ const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
+ spaceShooterLastTickMs = now;
+ tickPlayScrollBg(dt);
+ spaceShooterTickAsteroidExplosions(dt);
+
+ if (spaceShooterGameEnded) {
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+ return;
+ }
+ const remSec = spaceShooterRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endSpaceShooterTimeUp();
+ return;
+ }
+
+ const interval = spaceShooterAsteroidSpawnIntervalMsPlay();
+ spaceShooterSpawnAccMs += dt * 1000;
+ while (spaceShooterSpawnAccMs >= interval) {
+ spaceShooterSpawnAccMs -= interval;
+ const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp0 = maxHp;
+ spaceShooterAsteroids.push({
+ x: 36 + Math.random() * (mw - 72),
+ y: -45 - Math.random() * 90,
+ r: spaceShooterAsteroidRadiusFromHpPlay(hp0),
+ vy: 58 + Math.random() * 62,
+ maxHp: maxHp,
+ hp: hp0,
+ });
+ }
+
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
+ o.spaceShooterCx = (o.x + cw * 0.5) * ts;
+ o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
+ });
+
+ let vx = 0;
+ let vy = 0;
+ if (!isChatFocused()) {
+ if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
+ }
+ const moveSpd = 280;
+ const canPilotMe = !(isSpaceShooterMissionUiMapPlay() && me.spaceShooterEliminated);
+ if (canPilotMe) {
+ if (me.spaceShooterCx != null) {
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ me.spaceShooterCx = Math.max(padX, Math.min(mw - padX, me.spaceShooterCx + vx * moveSpd * dt));
+ }
+ if (me.spaceShooterCy != null) {
+ me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
+ }
+ syncSpaceShooterFootFromShipCenter(me);
+ }
+
+ spaceShooterFireCd -= dt;
+ const autoFireOk = canPilotMe && !isChatFocused();
+ if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
+ spaceShooterFireCd = 0.21;
+ spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
+ }
+
+ stepSpaceShooterPreviewBots(dt);
+
+ for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
+ const b = spaceShooterBullets[i];
+ b.y += b.vy * dt;
+ if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
+ }
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ a.y += a.vy * dt;
+ if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
+ }
+
+ spaceShooterMissionResolveShipAsteroidHitsPlay();
+
+ for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
+ const b = spaceShooterBullets[bi];
+ let hit = -1;
+ for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
+ const a = spaceShooterAsteroids[ai];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const rr = (a.r + 7) * (a.r + 7);
+ if (dx * dx + dy * dy <= rr) { hit = ai; break; }
+ }
+ if (hit >= 0) {
+ const a = spaceShooterAsteroids[hit];
+ const mh = Math.max(1, Math.floor(Number(a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1);
+ spaceShooterBullets.splice(bi, 1);
+ const add = 5;
+ if (b.ownerId === myId) {
+ me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ }
+ }
+ if (a.hp <= 0) {
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(hit, 1);
+ }
+ /** ขนาดคงที่จนแตก — ไม่ย่อ r ตาม HP ที่เหลือ (สลับสปรายต์ผ่าน spaceShooterAsteroidLiveSpriteIndexPlay) */
+ }
+ }
+
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ /* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+
+ me.direction = 'up';
+ me.isWalking = vx !== 0 || vy !== 0;
+ const tEmit = Date.now();
+ if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
+ spaceShooterLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
+ });
+ }
+ }
+
+ function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isSpaceShooter()) return;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
+
+ const astUrls = playSpaceShooterAsteroidSpriteUrls;
+
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ const [sx, sy] = worldToScreen(a.x, a.y);
+ const sr = Math.max(6, a.r * zDraw);
+ const si = spaceShooterAsteroidLiveSpriteIndexPlay(a);
+ let liveUrl = (astUrls && astUrls[si]) ? String(astUrls[si]).trim() : '';
+ if (!liveUrl && si > 0 && astUrls && astUrls[0]) liveUrl = String(astUrls[0]).trim();
+ const liveRec = liveUrl ? ensureGauntletAssetImage(liveUrl) : null;
+ const liveAstImg = liveRec && liveRec.ready && liveRec.img && liveRec.img.naturalWidth > 0 ? liveRec.img : null;
+ if (liveAstImg) {
+ const iw = liveAstImg.naturalWidth;
+ const ih = liveAstImg.naturalHeight;
+ const diam = sr * 2.15;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(liveAstImg, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ } else {
+ const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
+ grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
+ grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
+ grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
+ ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
+ ctx.shadowBlur = sr * 0.45;
+ ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ }
+
+ if (astUrls && astUrls.length >= 2) {
+ for (let ei = 0; ei < spaceShooterAsteroidExplosions.length; ei++) {
+ const ex = spaceShooterAsteroidExplosions[ei];
+ const u = astUrls[ex.fi + 1];
+ if (!u) continue;
+ const rec = ensureGauntletAssetImage(u);
+ const img = rec && rec.ready && rec.img && rec.img.naturalWidth > 0 ? rec.img : null;
+ if (!img) continue;
+ const [sx, sy] = worldToScreen(ex.x, ex.y);
+ const sr = Math.max(6, ex.r * zDraw);
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ const diam = sr * 2.4;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(img, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ }
+ }
+
+ for (let i = 0; i < spaceShooterBullets.length; i++) {
+ const b = spaceShooterBullets[i];
+ for (let k = 0; k < 4; k++) {
+ const t = k / 4;
+ const yy = b.y + t * 22;
+ const [bx, by] = worldToScreen(b.x, yy);
+ ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ const shipBody = spaceShooterShipBodyScreenPxPlay(zDraw);
+ const bodyW = shipBody.bodyW;
+ const bodyH = shipBody.bodyH;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
+ const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
+ const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated);
+ if (elimShip) return;
+ const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1)));
+ const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
+ const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null;
+ const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null;
+ let shipDrawW = bodyW * 2.2;
+ let shipDrawH = bodyH * 2.2;
+ if (shipImg) {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const iw = shipImg.naturalWidth;
+ const ih = shipImg.naturalHeight;
+ const maxW = bodyW * 2.4;
+ const maxH = bodyH * 2.4;
+ const sc = Math.min(maxW / iw, maxH / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ shipDrawW = dw;
+ shipDrawH = dh;
+ ctx.drawImage(shipImg, -dw * 0.5, -dh * 0.5, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ ctx.fillStyle = col;
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
+ ctx.lineWidth = Math.max(1.2, zDraw);
+ ctx.beginPath();
+ ctx.moveTo(0, -bodyH * 0.55);
+ ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
+ ctx.lineTo(0, bodyH * 0.22);
+ ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
+ ctx.lineTo(0, bodyH * 0.72);
+ ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ const hits = Math.max(0, Math.min(3, Number(ent.spaceShooterHits) || 0));
+ if (hits > 0 && playSpaceShooterShipDamageOverlayUrls && playSpaceShooterShipDamageOverlayUrls.length) {
+ const oUrl = (playSpaceShooterShipDamageOverlayUrls[hits - 1] || '').trim();
+ if (oUrl) {
+ const oRec = ensureGauntletAssetImage(oUrl);
+ const oImg = oRec && oRec.ready && oRec.img && oRec.img.naturalWidth > 0 ? oRec.img : null;
+ if (oImg) {
+ ctx.save();
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const ow = oImg.naturalWidth;
+ const oh = oImg.naturalHeight;
+ const scO = Math.min(shipDrawW / ow, shipDrawH / oh);
+ const odw = ow * scO;
+ const odh = oh * scO;
+ ctx.drawImage(oImg, sx - odw * 0.5, sy - odh * 0.5, odw, odh);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ const name = (ent.nickname || '').slice(0, 10);
+ if (name) {
+ ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
+ ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(name, sx, sy - bodyH * 0.62);
+ ctx.fillText(name, sx, sy - bodyH * 0.62);
+ ctx.textAlign = 'left';
+ }
+ });
+
+ for (let i = 0; i < spaceShooterPopups.length; i++) {
+ const p = spaceShooterPopups[i];
+ if (wallNow >= p.until) continue;
+ const [px, py] = worldToScreen(p.x, p.y);
+ const dur = 700;
+ const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
+ ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#ffe066';
+ ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(p.text || '+5', px, py - age * 18);
+ ctx.fillText(p.text || '+5', px, py - age * 18);
+ ctx.restore();
+ }
+ }
+
+ const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
+
+ function normalizeBalloonBossPlayerSlotsInPlay(md) {
+ if (!md || md.gameType !== 'balloon_boss') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.balloonBossPlayerSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.balloonBossPlayerSlots = rows;
+ }
+
+ function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.balloonBossPlayerSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.balloonBossPlayerSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function getBalloonBossBossWorldCenterPlay(md, ts) {
+ const bx = md && md.balloonBossBossSpawn;
+ if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
+ return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
+ }
+ const w = md.width || 20, h = md.height || 15;
+ return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
+ }
+
+ /**
+ * จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ)
+ * เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง)
+ */
+ function getBalloonBossBossLiveCenterPlay(md, ts) {
+ if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
+ const base = getBalloonBossBossWorldCenterPlay(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const tSec = balloonBossSessionStartMs > 0
+ ? (performance.now() - balloonBossSessionStartMs) / 1000
+ : performance.now() / 1000;
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return clampBalloonBossWorld(cx, cy, mw, mh);
+ }
+
+ /** จุดโลกประมาณกลางกระจุกลูกโป่ง (ใช้ hit จากกระสุนบอส) — ไม่ใช่จุดเท้า */
+ function balloonBossBalloonClusterWorldPlay(ent, bossCx, bossCy) {
+ if (!ent || ent.balloonBossCx == null) return { cx: 0, cy: 0 };
+ const dx = ent.balloonBossCx - bossCx;
+ const dy = ent.balloonBossCy - bossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const ox = (dx / L) * 22;
+ const oy = (dy / L) * 12;
+ return {
+ cx: ent.balloonBossCx + ox,
+ cy: ent.balloonBossCy - 52 + oy * 0.35,
+ };
+ }
+
+ /** ผู้เล่นหลักเด้งกับวงชนบอส (บอสเป็นวงนิ่งใน world) */
+ function balloonBossPlayerBossElasticPlay(meEnt, bossCx, bossCy, bossCollideR, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rB = Math.max(30, bossCollideR * 0.9);
+ const dx = meEnt.balloonBossCx - bossCx;
+ const dy = meEnt.balloonBossCy - bossCy;
+ const dist = Math.hypot(dx, dy) || 1e-6;
+ const minD = rMe + rB;
+ if (dist >= minD) return;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.58;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ const cl = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl.cx;
+ meEnt.balloonBossCy = cl.cy;
+ }
+
+ /** ผู้เล่นหลักเด้งกับคนอื่น (บอทขยับรับแรงปะทะเล็กน้อย) */
+ function balloonBossPlayerElasticCollisionsPlay(meEnt, aliveRefs, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rO = 44;
+ const minD = rMe + rO - 2;
+ for (let ii = 0; ii < aliveRefs.length; ii++) {
+ const entry = aliveRefs[ii];
+ if (!entry || entry.ref === meEnt) continue;
+ const o = entry.ref;
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) continue;
+ const dx = meEnt.balloonBossCx - o.balloonBossCx;
+ const dy = meEnt.balloonBossCy - o.balloonBossCy;
+ const dist = Math.hypot(dx, dy);
+ if (!dist || dist >= minD) continue;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.52;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ if (isPreviewBotId(entry.id)) {
+ const pushO = (minD - dist) * 0.48;
+ o.balloonBossCx -= nx * pushO;
+ o.balloonBossCy -= ny * pushO;
+ }
+ }
+ const cl2 = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl2.cx;
+ meEnt.balloonBossCy = cl2.cy;
+ }
+
+ /** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */
+ function getBalloonBossOccupiedSlotNumbersPlay(md) {
+ if (!md || !md.balloonBossPlayerSlots) return [];
+ const g = md.balloonBossPlayerSlots;
+ const w = md.width || 20, h = md.height || 15;
+ const seen = new Set();
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const v = g[y] && g[y][x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n));
+ }
+ }
+ return Array.from(seen).sort((a, b) => a - b);
+ }
+
+ function buildBalloonBossParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function balloonBossBalloonsStartPlay() {
+ const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
+ /** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */
+ return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3));
+ }
+
+ function balloonBossMaxHpPlay() {
+ const n = Number(mapData && mapData.balloonBossMaxHp);
+ return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
+ }
+
+ function balloonBossFireDelayMsPlay() {
+ const n = Number(mapData && mapData.balloonBossFireDelayMs);
+ return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
+ }
+
+ /** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
+ function balloonBossShipAccelPlay() {
+ const n = Number(mapData && mapData.balloonBossShipAccel);
+ return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
+ }
+
+ /** ความเร็วสูงสุดยาน (px/s) */
+ function balloonBossShipMaxSpeedPlay() {
+ const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
+ return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
+ }
+
+ /** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
+ function balloonBossShipDragPlay() {
+ const n = Number(mapData && mapData.balloonBossShipDrag);
+ return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
+ }
+
+ function balloonBossTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.balloonBossTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playBalloonBossMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 120;
+ }
+
+ function balloonBossRemainingSecPlay() {
+ const lim = balloonBossTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim;
+ if (!balloonBossSessionStartMs) return lim;
+ const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function balloonBossTeamDamagePlay() {
+ let s = Math.max(0, me.balloonBossScore | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
+ return s;
+ }
+
+ /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */
+ function balloonBossTeamBossDamagePlay() {
+ let s = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); });
+ return s;
+ }
+
+ function syncBalloonBossFootFromCenter(ent) {
+ if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ }
+
+ function clampBalloonBossWorld(cx, cy, mw, mh) {
+ const e = 22;
+ return {
+ cx: Math.max(e, Math.min(mw - e, cx)),
+ cy: Math.max(e, Math.min(mh - e, cy)),
+ };
+ }
+
+ function applyBalloonBossSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const n = refs.length || 1;
+ const bStart = balloonBossBalloonsStartPlay();
+ const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData);
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((idx % 6) + 1);
+ const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ const t = (idx + 1) / (n + 1);
+ cx = t * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
+ }
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ ent.balloonBossSkinSlot = slot;
+ ent.balloonBossCx = cl.cx;
+ ent.balloonBossCy = cl.cy;
+ if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
+ if (typeof ent.balloonBossBossDmg !== 'number' || !Number.isFinite(ent.balloonBossBossDmg)) ent.balloonBossBossDmg = 0;
+ /** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */
+ ent.balloonBossBalloons = bStart;
+ if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
+ ent.balloonBossVelX = 0;
+ ent.balloonBossVelY = 0;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'down';
+ if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) {
+ ent.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ });
+ }
+
+ function endBalloonBossGame(reason) {
+ if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
+ balloonBossGameEnded = true;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossScorePopups = [];
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyMegaVirusMissionPanelImages();
+ beginBalloonBossMegaVirusResultFlashSequenceThenGcm(balloonBossBuildMissionPayloadPlay(reason), reason);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
+ }
+ msgEl.textContent = reason === 'victory'
+ ? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากคะแนนการยิงโดน'
+ : 'หมดเวลา — อันดับจากคะแนนการยิงโดน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)} pts`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'balloon_boss') return;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
+ const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
+ const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
+ const spd = 48;
+ let nx = o.balloonBossCx + wob * spd * dt;
+ let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
+ const cl = clampBalloonBossWorld(nx, ny, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ syncBalloonBossFootFromCenter(o);
+ o.direction = 'down';
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
+ else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
+ if (o.balloonBossBotNextFire <= 0) {
+ o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const dx = bossCx - o.balloonBossCx;
+ const dy = bossCy - o.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const bspd = 500 + Math.random() * 90;
+ balloonBossPendingShots.push({
+ releaseAt: performance.now() + delayMs,
+ sx: o.balloonBossCx, sy: o.balloonBossCy,
+ vx: (dx / L) * bspd,
+ vy: (dy / L) * bspd,
+ ownerId: bid,
+ });
+ }
+ });
+ }
+
+ function balloonBossTickFrame() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (!o || o.balloonBossEliminated) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
+ const cx = (o.x + cw * 0.5) * ts;
+ const cy = (o.y + ch * 0.92) * ts;
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ });
+ const now = performance.now();
+ const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
+ balloonBossLastTickMs = now;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const bossR = Math.max(36, ts * 1.15);
+ const maxHp = balloonBossMaxHpPlay();
+ const teamBossDmg = balloonBossTeamBossDamagePlay();
+
+ if (me.balloonBossEliminated) {
+ me.balloonBossVelX = 0;
+ me.balloonBossVelY = 0;
+ }
+ if (balloonBossGameEnded) {
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+ return;
+ }
+ const remSec = balloonBossRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endBalloonBossGame('time');
+ return;
+ }
+ if (teamBossDmg >= maxHp) {
+ endBalloonBossGame('victory');
+ return;
+ }
+ const allRefsEarly = buildBalloonBossParticipantRefsPlay();
+ if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) {
+ endBalloonBossGame('all_dead');
+ return;
+ }
+
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+
+ const aliveRefs = allRefsEarly.filter((e) => e.ref && !e.ref.balloonBossEliminated);
+
+ for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
+ const pnd = balloonBossPendingShots[pi];
+ if (now >= pnd.releaseAt) {
+ balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
+ balloonBossPendingShots.splice(pi, 1);
+ }
+ }
+
+ const bossFireEvery = 1.42;
+ balloonBossBossFireAcc += dt;
+ while (balloonBossBossFireAcc >= bossFireEvery) {
+ balloonBossBossFireAcc -= bossFireEvery;
+ if (aliveRefs.length) {
+ const ang = Math.random() * Math.PI * 2;
+ const spd = 88 + Math.random() * 72;
+ balloonBossBossBullets.push({
+ x: bossC.cx, y: bossC.cy,
+ vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
+ gy: 48 + Math.random() * 56,
+ });
+ }
+ }
+
+ function applyBalloonBossHitToEntity(ent) {
+ if (!ent || ent.balloonBossEliminated) return;
+ if (ent.balloonBossHitIframe > now) return;
+ ent.balloonBossHitIframe = now + 520;
+ ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
+ if (ent.balloonBossBalloons <= 0) {
+ ent.balloonBossEliminated = true;
+ ent.balloonBossBalloons = 0;
+ }
+ if (ent === me && socket) {
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossBalloons: me.balloonBossBalloons,
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ });
+ }
+ }
+
+ for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
+ const b = balloonBossBossBullets[bi];
+ const gy = typeof b.gy === 'number' && Number.isFinite(b.gy) ? b.gy : 62;
+ b.vy += gy * dt;
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
+ balloonBossBossBullets.splice(bi, 1);
+ continue;
+ }
+ let hit = false;
+ aliveRefs.forEach((entry) => {
+ if (hit) return;
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) return;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const dx = b.x - cc.cx, dy = b.y - cc.cy;
+ if (dx * dx + dy * dy <= (36 * 36)) {
+ hit = true;
+ applyBalloonBossHitToEntity(ent);
+ }
+ });
+ if (hit) balloonBossBossBullets.splice(bi, 1);
+ }
+
+ for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
+ const b = balloonBossPlayerBullets[i];
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
+ balloonBossPlayerBullets.splice(i, 1);
+ continue;
+ }
+ let pHit = false;
+ for (let ji = 0; ji < aliveRefs.length; ji++) {
+ if (pHit) break;
+ const entry = aliveRefs[ji];
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) continue;
+ if (String(entry.id) === String(b.ownerId)) continue;
+ const canBalloonHit = isPreviewBotId(entry.id) || ent === me;
+ if (!canBalloonHit) continue;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const pdx = b.x - cc.cx, pdy = b.y - cc.cy;
+ if (pdx * pdx + pdy * pdy <= (34 * 34)) {
+ applyBalloonBossHitToEntity(ent);
+ balloonBossPlayerBullets.splice(i, 1);
+ pHit = true;
+ }
+ }
+ if (pHit) continue;
+ const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
+ if (dx * dx + dy * dy <= bossR * bossR) {
+ balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
+ balloonBossScorePopups.push({
+ ownerId: b.ownerId,
+ x: b.x,
+ y: b.y,
+ t: 0.85,
+ tMax: 0.85,
+ });
+ const addScore = 10;
+ const addBossDmg = 2;
+ if (b.ownerId === myId) {
+ me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore);
+ me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg);
+ if (socket && myId != null) {
+ balloonBossLastMoveEmit = Date.now();
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore);
+ o.balloonBossBossDmg = Math.max(0, (o.balloonBossBossDmg | 0) + addBossDmg);
+ }
+ }
+ balloonBossPlayerBullets.splice(i, 1);
+ }
+ }
+
+ const tWind = performance.now() * 0.001;
+ const gustAx = Math.sin(tWind * 0.36 + 1.15) * 62 + Math.sin(tWind * 1.05) * 24;
+ const gustAy = Math.cos(tWind * 0.39 + 0.28) * 58 + Math.cos(tWind * 0.93) * 22;
+ const maxSpd = Math.min(228, balloonBossShipMaxSpeedPlay() * 1.32);
+ const dragPerSec = balloonBossShipDragPlay() * 0.4;
+ if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
+ if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
+ let nvx = me.balloonBossVelX;
+ let nvy = me.balloonBossVelY;
+ nvx += gustAx * dt;
+ nvy += gustAy * dt;
+ const drag = Math.min(1, dragPerSec * dt);
+ nvx *= (1 - drag);
+ nvy *= (1 - drag);
+ const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
+ if (curSpd > maxSpd) {
+ const s = maxSpd / curSpd;
+ nvx *= s;
+ nvy *= s;
+ }
+ me.balloonBossVelX = nvx;
+ me.balloonBossVelY = nvy;
+ if (!me.balloonBossEliminated && me.balloonBossCx != null) {
+ const ncx = me.balloonBossCx + nvx * dt;
+ const ncy = me.balloonBossCy + nvy * dt;
+ const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
+ if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX *= -0.88;
+ if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY *= -0.88;
+ me.balloonBossCx = cl.cx;
+ me.balloonBossCy = cl.cy;
+ }
+ syncBalloonBossFootFromCenter(me);
+ balloonBossPlayerElasticCollisionsPlay(me, aliveRefs, mw, mh);
+ balloonBossPlayerBossElasticPlay(me, bossC.cx, bossC.cy, bossR, mw, mh);
+ syncBalloonBossFootFromCenter(me);
+ /** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */
+ me.direction = 'down';
+ me.isWalking = Math.abs(me.balloonBossVelX) > 12 || Math.abs(me.balloonBossVelY) > 12;
+
+ /** วงลูกศรหมุนเองเรื่อย ๆ (คุมไม่ได้) — Mega Virus ยิงตามมุมลูกศร ณ ตอนกด Space */
+ aliveRefs.forEach((e) => {
+ const r = e.ref;
+ if (!r || r.balloonBossEliminated) return;
+ if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) {
+ r.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ r.balloonBossAimRad += 1.02 * dt;
+ });
+
+ balloonBossPlayerFireCd -= dt;
+ const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
+ if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
+ balloonBossPlayerFireCd = 0.35;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const bspd = 480;
+ let vx;
+ let vy;
+ if (isMegaVirusMissionShellMapPlay()) {
+ const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad))
+ ? me.balloonBossAimRad
+ : Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx);
+ vx = Math.cos(aim) * bspd;
+ vy = Math.sin(aim) * bspd;
+ } else {
+ const dx = bossC.cx - me.balloonBossCx;
+ const dy = bossC.cy - me.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ vx = (dx / L) * bspd;
+ vy = (dy / L) * bspd;
+ }
+ balloonBossPendingShots.push({
+ releaseAt: now + delayMs,
+ sx: me.balloonBossCx, sy: me.balloonBossCy,
+ vx,
+ vy,
+ ownerId: myId,
+ });
+ }
+
+ stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
+
+ const tEmit = Date.now();
+ if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
+ balloonBossLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ }
+
+ function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isBalloonBoss()) return;
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const maxHp = balloonBossMaxHpPlay();
+ const dmg = balloonBossTeamBossDamagePlay();
+ const hpLeft = Math.max(0, maxHp - dmg);
+ const bossR = Math.max(36, ts * 1.15);
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const leaderId = (() => {
+ let best = null, bs = -1;
+ refs.forEach((e) => {
+ if (!e.ref || e.ref.balloonBossEliminated) return;
+ const sc = Math.max(0, e.ref.balloonBossScore | 0);
+ if (sc > bs) { bs = sc; best = e.id; }
+ });
+ return best;
+ })();
+ const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || ''));
+ const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null;
+
+ for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
+ const fx = balloonBossHitFx[hi];
+ const [hx, hy] = worldToScreen(fx.x, fx.y);
+ const pulse = 1 + (fx.t || 0) * 3;
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
+ ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
+ ctx.lineWidth = 3 * zDraw;
+ ctx.beginPath();
+ ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ const score10PopUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/score+10.png');
+ const score10PopRec = score10PopUrl ? ensureGauntletAssetImage(score10PopUrl) : null;
+ for (let si = 0; si < balloonBossScorePopups.length; si++) {
+ const sp = balloonBossScorePopups[si];
+ let wx = sp.x;
+ let wy = sp.y;
+ if (sp.ownerId != null) {
+ const oid = sp.ownerId;
+ const ent = (myId != null && String(oid) === String(myId)) ? me : others.get(oid);
+ if (ent && ent.balloonBossCx != null && !ent.balloonBossEliminated) {
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const tMax = typeof sp.tMax === 'number' && sp.tMax > 0 ? sp.tMax : 0.95;
+ const rise = ((tMax - Math.max(0, sp.t || 0)) / tMax) * 58;
+ wx = cc.cx;
+ wy = cc.cy - 46 - rise;
+ }
+ }
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, Math.max(0, sp.t || 0) * 1.12);
+ if (sp.dmgTxt) {
+ const fpx = Math.max(11, 14 * zDraw);
+ ctx.font = `bold ${fpx}px ui-sans-serif, system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.62)';
+ ctx.lineWidth = Math.max(2, 2.4 * zDraw);
+ ctx.strokeText(String(sp.dmgTxt), sx, sy);
+ ctx.fillStyle = '#f8fafc';
+ ctx.fillText(String(sp.dmgTxt), sx, sy);
+ } else if (score10PopRec && score10PopRec.ready && score10PopRec.img && score10PopRec.img.naturalWidth > 0) {
+ const iw = score10PopRec.img.naturalWidth;
+ const ih = score10PopRec.img.naturalHeight;
+ const mh = Math.max(14, 20 * zDraw);
+ const sc = Math.min(1.35, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(score10PopRec.img, sx - dw / 2, sy - dh / 2, dw, dh);
+ }
+ ctx.restore();
+ }
+
+ const bossAlive = hpLeft > 0;
+ if (bossAlive) {
+ const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
+ ctx.save();
+ ctx.translate(bsx, bsy);
+ const limR = bossR * zDraw;
+ if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) {
+ const iw = bossImgRec.img.naturalWidth;
+ const ih = bossImgRec.img.naturalHeight;
+ const diam = limR * 2.1;
+ const scale = Math.min(diam / iw, diam / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.beginPath();
+ for (let k = 0; k < 6; k++) {
+ const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
+ const px = Math.cos(ang) * bossR * zDraw * 1.05;
+ const py = Math.sin(ang) * bossR * zDraw * 1.05;
+ if (k === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
+ ctx.fill();
+
+ const skullR = bossR * zDraw * 0.62;
+ const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
+ grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
+ grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(0, 0, skullR, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
+ ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
+ ctx.shadowBlur = 12 * zDraw;
+ ctx.beginPath();
+ ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ /** HUD บอส: แถบชิดเหนือหัวบอส; โลโก้ Artboard10 + [hp/max] เหนือแถบ — bbHudS ลดสเกลทั้งแถว (ขอให้เล็กลง ~ครึ่ง) */
+ const bbHudS = 0.5;
+ const fontPx = Math.max(8, 12.5 * zDraw * bbHudS);
+ ctx.font = `bold ${fontPx}px ui-monospace, monospace`;
+ ctx.textBaseline = 'middle';
+ const hpStr = '[ ' + hpLeft + ' / ' + maxHp + ' ]';
+ const hpW = ctx.measureText(hpStr).width;
+ const gapHp = 5 * zDraw * bbHudS;
+
+ const powBgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power-bg.png');
+ const powFillUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power.png');
+ const powBgRec = powBgUrl ? ensureGauntletAssetImage(powBgUrl) : null;
+ const powFillRec = powFillUrl ? ensureGauntletAssetImage(powFillUrl) : null;
+
+ /** ความสูงหลอด: ยึดความกว้างก่อน — คูณ bbHudS ให้สัดส่วนเทียบหัวบอสเล็กลง */
+ const barHMin = Math.max(9, 10.5 * zDraw) * bbHudS;
+ const barHMax = Math.max(17, 21 * zDraw) * bbHudS;
+
+ const titleImgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/Artboard%2010.png');
+ const titleImgRec = titleImgUrl ? ensureGauntletAssetImage(titleImgUrl) : null;
+ let imgW = 0;
+ let imgH = 0;
+ if (titleImgRec && titleImgRec.ready && titleImgRec.img && titleImgRec.img.naturalWidth > 0) {
+ const iw = titleImgRec.img.naturalWidth;
+ const ih = titleImgRec.img.naturalHeight;
+ let barHForTitle = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const biw = powBgRec.img.naturalWidth;
+ const bijh = powBgRec.img.naturalHeight;
+ const wEst = Math.max(limR * 2.12 * bbHudS, hpW + gapHp + iw * 0.35 * bbHudS);
+ barHForTitle = Math.min(barHMax, Math.max(barHMin, bijh * (wEst / biw)));
+ }
+ const capTitle = Math.min(17 * zDraw * bbHudS, Math.max(fontPx * 1.15, barHForTitle * 0.88));
+ const maxH = Math.max(fontPx * 1.12, 14 * zDraw * bbHudS, capTitle);
+ const sc = Math.min(1.0, maxH / ih);
+ imgW = iw * sc;
+ imgH = ih * sc;
+ }
+
+ let rowW = (imgW > 0 ? imgW + gapHp : 0) + hpW;
+ if (imgW <= 0) {
+ const legacyPre = 'MEGA VIRUS : ';
+ rowW = ctx.measureText(legacyPre).width + hpW;
+ }
+ const barPadRow = 8 * zDraw * bbHudS;
+ const barWTarget = Math.max(limR * 2.08 * bbHudS, rowW + barPadRow);
+
+ let barDw = barWTarget;
+ let barDh = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const iw = powBgRec.img.naturalWidth;
+ const ih = powBgRec.img.naturalHeight;
+ const naturalH = ih * (barWTarget / iw);
+ barDh = Math.min(barHMax, Math.max(barHMin, naturalH));
+ }
+
+ const gapBarBoss = 4 * zDraw * bbHudS;
+ const gapTitleBar = 5 * zDraw * bbHudS;
+ const barBottomEdge = -limR * 1.0 - gapBarBoss;
+ const barTopY = barBottomEdge - barDh;
+ const bx = -barDw / 2;
+ const by = barTopY;
+ const ratHp = Math.max(0, Math.min(1, hpLeft / maxHp));
+
+ const titleRowH = Math.max(imgH, fontPx * 1.12);
+ const labelY = barTopY - gapTitleBar - titleRowH * 0.5;
+
+ let xLeft = -(imgW + (imgW > 0 ? gapHp : 0) + hpW) / 2;
+ if (imgW > 0) {
+ ctx.drawImage(titleImgRec.img, xLeft, labelY - imgH / 2, imgW, imgH);
+ xLeft += imgW + gapHp;
+ } else {
+ ctx.textAlign = 'left';
+ ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
+ const legacyPre = 'MEGA VIRUS : ';
+ const pw = ctx.measureText(legacyPre).width;
+ xLeft = -(pw + hpW) / 2;
+ ctx.fillText(legacyPre, xLeft, labelY);
+ xLeft += pw;
+ }
+ ctx.textAlign = 'left';
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
+ ctx.lineWidth = Math.max(0.5, 1 * zDraw * bbHudS);
+ ctx.strokeText(hpStr, xLeft, labelY);
+ ctx.fillText(hpStr, xLeft, labelY);
+
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ ctx.drawImage(powBgRec.img, bx, by, barDw, barDh);
+ if (ratHp > 0 && powFillRec && powFillRec.ready && powFillRec.img && powFillRec.img.naturalWidth > 0) {
+ const fiw = powFillRec.img.naturalWidth;
+ const fih = powFillRec.img.naturalHeight;
+ const padIn = barDw * 0.04;
+ const innerW = barDw - padIn * 2;
+ const innerH = barDh - padIn * 1.2;
+ const fsc2 = Math.min(innerW / fiw, innerH / fih);
+ const fdw2 = fiw * fsc2;
+ const fdh2 = fih * fsc2;
+ const fx0 = bx + (barDw - fdw2) * 0.5;
+ const fy0 = by + (barDh - fdh2) * 0.5;
+ const srcW = fiw * ratHp;
+ ctx.drawImage(powFillRec.img, 0, 0, srcW, fih, fx0, fy0, fdw2 * ratHp, fdh2);
+ }
+ } else {
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
+ ctx.fillRect(bx, by, barDw, barDh);
+ ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
+ ctx.fillRect(bx, by, barDw * ratHp, barDh);
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.strokeRect(bx, by, barDw, barDh);
+ }
+ ctx.restore();
+ }
+
+ /** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */
+ function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) {
+ const lx = 0;
+ const ly = dh * 0.5;
+ const c = Math.cos(tiltRad);
+ const s = Math.sin(tiltRad);
+ return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c };
+ }
+
+ /** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
+ function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
+ const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
+ const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
+ const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
+ const n = Math.max(0, Math.min(8, count | 0));
+ const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
+ const sz = 1.5;
+ const tw = 22 * sz * z;
+ const th = 27 * sz * z;
+ const stackAnchor = ringRpx + 10 * z;
+ const tieX = sx;
+ const tieY = sy - ringRpx * 0.98;
+ const tiltStep = 0.5;
+ const spreadX = 5.4 * z;
+ const baseY = sy - stackAnchor - 1.5 * z;
+ const items = [];
+ for (let b = 0; b < n; b++) {
+ const rel = b - (n - 1) / 2;
+ const tilt = rel * tiltStep;
+ const bx = sx + rel * spreadX;
+ const by = baseY + Math.abs(rel) * 2.1 * z;
+ const bImg = bRec && bRec.img;
+ let dw = 0;
+ let dh = 0;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ dw = iw * sc;
+ dh = ih * sc;
+ } else {
+ const ry = 12 * sz * z;
+ dw = 10 * sz * z * 2;
+ dh = ry * 2;
+ }
+ const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt);
+ items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y });
+ }
+ if (n > 0) {
+ ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)';
+ ctx.lineWidth = Math.max(0.65, 0.78 * z);
+ ctx.lineCap = 'butt';
+ ctx.lineJoin = 'bevel';
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ ctx.beginPath();
+ ctx.moveTo(tieX, tieY);
+ ctx.lineTo(it.knotX, it.knotY);
+ ctx.stroke();
+ }
+ }
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ const bImg = it.bImg;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+ }
+
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ if (ent.balloonBossEliminated) return;
+ const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
+ const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
+ const z = zDraw;
+ const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6)
+ ? ent.balloonBossSkinSlot
+ : ((idx % 6) + 1);
+ const ringR = 25 * z;
+ /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
+ const bubbleCy = sy - ringR * 0.42;
+ /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
+ const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad))
+ ? ent.balloonBossAimRad
+ : ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2));
+ /** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
+ const faceDir = 'down';
+ const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
+ const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim();
+ const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ /** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */
+ const ringRec = fbRing
+ ? resolveBalloonBossBalloonSpriteRec('', fbRing)
+ : resolveBalloonBossBalloonSpriteRec(perRing, '');
+ ctx.save();
+ const ringImg = ringRec && ringRec.img;
+ const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0);
+ if (ringPainted) {
+ const iw = ringImg.naturalWidth;
+ const ih = ringImg.naturalHeight;
+ const maxBox = 2 * ringR * 1.14;
+ const sc = Math.min(maxBox / iw, maxBox / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ /** หางฟอง (Artboard +Y) หมุนตาม angRing — tail ชี้ทิศยิง */
+ ctx.save();
+ ctx.translate(sx, bubbleCy);
+ ctx.rotate(angRing - Math.PI / 2);
+ ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ }
+ if (!ringPainted) {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)';
+ ctx.lineWidth = Math.max(1.2, 1.6 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
+ ctx.lineWidth = Math.max(1, 1.1 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR);
+ const nm = String(ent.nickname || '').slice(0, 12);
+ ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
+ ctx.lineWidth = 3;
+ let label = nm;
+ if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
+ const labelY = bubbleCy + ringR + 8 * z;
+ ctx.strokeText(label, sx, labelY);
+ ctx.fillText(label, sx, labelY);
+ const charImg = resolvePlayDrawCharSource(
+ ent.characterId, faceDir, timeMs, !!ent.isWalking,
+ ent.playTint || resolvePlayTintForPeer(entry.id),
+ myId != null && entry.id != null && String(entry.id) === String(myId),
+ );
+ const size = 26 * z;
+ if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
+ const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
+ const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
+ const sc = Math.min(size / iw, size / ih, 1);
+ const dw = iw * sc, dh = ih * sc;
+ const charTop = bubbleCy - dh * 0.48;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh);
+ } else {
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)';
+ ctx.lineWidth = Math.max(1, 1.2 * z);
+ ctx.stroke();
+ }
+ ctx.restore();
+ });
+
+ const bulletCodeUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/bullet-code.png');
+ const bulletCodeRec = bulletCodeUrl ? ensureGauntletAssetImage(bulletCodeUrl) : null;
+ for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
+ const b = balloonBossPlayerBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.save();
+ ctx.translate(bx, by);
+ /** bullet-code.png หัวลูกศรชี้ขวา (+x) — หมุนตามทิศความเร็วโดยไม่บวก π/2 (ก่อนหน้านี้ดูเหมือนยิงเฉียง) */
+ ctx.rotate(Math.atan2(b.vy, b.vx));
+ if (bulletCodeRec && bulletCodeRec.ready && bulletCodeRec.img && bulletCodeRec.img.naturalWidth > 0) {
+ const iw = bulletCodeRec.img.naturalWidth;
+ const ih = bulletCodeRec.img.naturalHeight;
+ const mh = Math.max(10, 15 * zDraw);
+ const sc = Math.min(1.4, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(bulletCodeRec.img, -dw / 2, -dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(0, -8 * zDraw);
+ ctx.lineTo(-5 * zDraw, 6 * zDraw);
+ ctx.lineTo(5 * zDraw, 6 * zDraw);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
+ ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
+ ctx.textAlign = 'center';
+ ctx.fillText('01001', 0, 12 * zDraw);
+ }
+ ctx.restore();
+ }
+ for (let i = 0; i < balloonBossBossBullets.length; i++) {
+ const b = balloonBossBossBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
+ ctx.stroke();
+ }
+ }
+
+ function jumpSurviveCollectPlatformCells(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ const out = [];
+ if (!pa) return out;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (pa[y] && pa[y][x] === 1) out.push({ x, y });
+ }
+ }
+ return out;
+ }
+
+ /** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกล่างจะวนมาโผล่บน */
+ function jumpSurvivePlatformPeriodPx(md) {
+ return (md.height || 15) * tileSize;
+ }
+
+ /**
+ * ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
+ */
+ function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
+ const ts = tileSize;
+ const h = md.height || 15;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const rawTop = ty * ts + scrollPx;
+ const hintY = (jumpSurviveCamCenterY > 1)
+ ? jumpSurviveCamCenterY
+ : h * ts * 0.72;
+ const k = Math.round((hintY - rawTop - ts * 0.35) / period);
+ const platTop = rawTop + k * period;
+ const feetY = platTop;
+ const pxc = (tx + 0.5) * ts;
+ return {
+ x: pxc / ts - cw * 0.5,
+ y: feetY / ts - ch,
+ };
+ }
+
+ function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
+ return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
+ }
+
+ function jumpSurviveRandomStandGridPos(md) {
+ const cells = jumpSurviveCollectPlatformCells(md);
+ const sc = jumpSurvivePlatformScrollPx;
+ if (cells.length) {
+ const c = cells[Math.floor(Math.random() * cells.length)];
+ return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
+ }
+
+ function jumpSurviveTileHasPlatformCellPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ const h = md.height || (pa && pa.length) || 15;
+ const w = md.width || 20;
+ if (!pa || ty < 0 || ty >= h) return false;
+ const row = pa[ty];
+ if (!row || tx < 0 || tx >= w) return false;
+ return row[tx] === 1;
+ }
+
+ /** หาแพลตฟอร์มในคอลัมน์ tx ใกล้ ty — กันจุดเกิดช่องว่างแล้วลอย */
+ function jumpSurviveFindPlatformColumnPlay(md, tx, tyHint) {
+ const w = md.width || 20, h = md.height || 15;
+ const tcx = Math.max(0, Math.min(w - 1, Math.floor(tx)));
+ let ty = Math.max(0, Math.min(h - 1, Math.floor(tyHint)));
+ if (jumpSurviveTileHasPlatformCellPlay(md, tcx, ty)) return { x: tcx, y: ty };
+ for (let d = 1; d < h; d++) {
+ const yb = ty + d;
+ if (yb < h && jumpSurviveTileHasPlatformCellPlay(md, tcx, yb)) return { x: tcx, y: yb };
+ const ya = ty - d;
+ if (ya >= 0 && jumpSurviveTileHasPlatformCellPlay(md, tcx, ya)) return { x: tcx, y: ya };
+ }
+ return { x: tcx, y: ty };
+ }
+
+ /** จุดเกิดตามลำดับ join / P1–P6 → world ยืนบนแพลตฟอร์ม (ไม่ทับสุ่ม pool) */
+ function jumpSurviveSpawnWorldFromJoinOrderPlay(md, joinOrder) {
+ const sp = pickSpawnForJoinPlay(md, joinOrder | 0);
+ let tx = Math.floor(Number(sp.x));
+ let ty = Math.floor(Number(sp.y));
+ if (!Number.isFinite(tx) || !Number.isFinite(ty)) {
+ const fb = md.spawn || { x: 1, y: 1 };
+ tx = Math.floor(Number(fb.x)) || 1;
+ ty = Math.floor(Number(fb.y)) || 1;
+ }
+ const cell = jumpSurviveFindPlatformColumnPlay(md, tx, ty);
+ const sc = jumpSurvivePlatformScrollPx;
+ const p = jumpSurviveGridPosStandingOnPlatform(md, cell.x, cell.y, sc);
+ return {
+ x: p.x + (Math.random() - 0.5) * 0.05,
+ y: p.y + (Math.random() - 0.5) * 0.05,
+ };
+ }
+
+ /** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
+ /** Quiz Battle พรีวิว: บอท spawn ตามโหมดแมป — ผู้เล่นจริงใช้ตำแหน่งจาก server แล้ว snap เท่าที่จำเป็น */
+ function applyQuizBattlePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'quiz_battle' || !quizBattlePathModeActive(mapData)) return;
+
+ function stampQuizEnt(ent, joinOrd) {
+ if (!ent) return;
+ const jo = Number.isFinite(Number(joinOrd)) ? Math.max(0, Math.floor(Number(joinOrd))) : 0;
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ ent.tx = pos.x;
+ ent.ty = pos.y;
+ }
+
+ if (!onlyBots) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = sn.x;
+ me.y = sn.y;
+ me.tx = sn.x;
+ me.ty = sn.y;
+ }
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ if (!onlyBots) {
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : realIds.indexOf(rid);
+ stampQuizEnt(o, jo);
+ });
+ }
+ botIds.forEach((bid, bi) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : countPlayHumans() + bi;
+ stampQuizEnt(o, jo);
+ });
+ }
+
+ function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'jump_survive') return;
+
+ function stampJumpBotState(o) {
+ if (!o) return;
+ o.jumpSurviveVy = 0;
+ o.jumpSurviveOnGround = true;
+ o.jumpSurviveEliminated = false;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+
+ if (isJumpSurviveMissionUiMapPlay()) {
+ function snapEnt(ent) {
+ if (!ent) return;
+ const ordRaw = Number(ent.spawnJoinOrder);
+ const jo = Number.isFinite(ordRaw) ? Math.max(0, Math.floor(ordRaw)) : 0;
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ stampJumpBotState(ent);
+ }
+ if (!onlyBots) {
+ snapEnt(me);
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ realIds.forEach((rid) => snapEnt(others.get(rid)));
+ }
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((bid) => snapEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ return;
+ }
+
+ const cells = jumpSurviveCollectPlatformCells(mapData);
+ cells.sort((a, b) => b.y - a.y || a.x - b.x);
+ const sc = jumpSurvivePlatformScrollPx;
+ const pool = cells.length
+ ? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
+ : [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+
+ if (onlyBots) {
+ let idx = 1 + realIds.length;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ o.x = p.x + (Math.random() - 0.5) * 0.08;
+ o.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(o);
+ });
+ return;
+ }
+
+ let idx = 0;
+ function assignEnt(ent) {
+ if (!ent || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ ent.x = p.x + (Math.random() - 0.5) * 0.08;
+ ent.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(ent);
+ }
+ assignEnt(me);
+ realIds.forEach((rid) => assignEnt(others.get(rid)));
+ botIds.forEach((bid) => assignEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ }
+
+ /**
+ * ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
+ * ตายจาก: hazard ที่วาง, บีบระหว่าง solid+object wall, หรือตกออกนอกขอบโลกแมป (ไม่ใช้ขอบกล้อง)
+ * @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
+ */
+ function jumpSurvivePhysicsIntegrate(md, opts) {
+ const {
+ dt,
+ cw, ch,
+ pxc0, feetY0, vy0, wasOnGround,
+ jumpQueued,
+ moveLeft, moveRight,
+ } = opts;
+ const w = md.width || 20, h = md.height || 15;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const bh = ts * 0.9;
+ let pxc = pxc0;
+ let feetY = feetY0;
+ let vy = vy0;
+ let onGround = wasOnGround;
+
+ const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
+ const g = gFull * dt;
+ let jumpV0;
+ if (Number(md.jumpSurviveJumpImpulse) > 0) {
+ jumpV0 = -Number(md.jumpSurviveJumpImpulse);
+ } else {
+ const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
+ ? playJumpSurviveJumpHeightMult
+ : 1.5;
+ const charHpixels = ch * ts;
+ const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
+ jumpV0 = -Math.min(impulse, ts * 36);
+ }
+ let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
+ if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35;
+
+ if (jumpQueued && onGround) {
+ vy = jumpV0;
+ onGround = false;
+ }
+ vy += g;
+
+ let nx = pxc;
+ if (moveLeft) nx -= moveX;
+ if (moveRight) nx += moveX;
+ nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
+
+ const headY0 = feetY - bh;
+ const tryL = nx - bw;
+ const tryR = nx + bw;
+ if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) {
+ pxc = nx;
+ }
+
+ feetY += vy * dt;
+ let headTop = feetY - bh;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ if (vy > 0) {
+ const step = ts * 0.035;
+ const penetrationEst = Math.abs(vy) * dt + ts * 0.55;
+ const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6));
+ for (let s = 0; s < maxSteps; s++) {
+ feetY -= step;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ onGround = true;
+ break;
+ }
+ }
+ } else if (vy < 0) {
+ const stepUp = ts * 0.035;
+ const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55;
+ const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6));
+ for (let s = 0; s < maxStepsUp; s++) {
+ feetY += stepUp;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ break;
+ }
+ }
+ }
+ } else {
+ onGround = false;
+ }
+
+ headTop = feetY - bh;
+ const headBandBottom = headTop + bh * 0.42;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
+ jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ /* ไม่ใช้ขอบกล้อง (cam topKill/botKill) — ตายเฉพาะ hazard ที่วาง + ตกออกนอกโลกแมปจริง (ไม่ตายในอากาศระหว่างแพลตฟอร์ม) */
+ const mapHpx = h * ts;
+ if (feetY > mapHpx + ts * 8 || headTop < -ts * 32) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ return { pxc, feetY, vy, onGround, died: false };
+ }
+
+ function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ o.x = pxc / tileSize - cw * 0.5;
+ o.y = feetYpx / tileSize - ch;
+ }
+
+ function jumpSurviveInitRuntime() {
+ jumpSurviveLastTickMs = performance.now();
+ jumpSurviveSessionStartMs = performance.now();
+ jumpSurviveVy = 0;
+ jumpSurviveJumpQueued = false;
+ jumpSurviveOnGround = false;
+ jumpSurvivePlatformScrollPx = 0;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ jumpSurviveCamCenterX = (w * ts) * 0.5;
+ jumpSurviveCamCenterY = (h * ts) * 0.5;
+ } else {
+ jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
+ jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
+ }
+ }
+
+ function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w) return true;
+ if (ty < 0) continue;
+ /* แพลตฟอร์มวนแนวตั้ง (period = h*ts) — worldY เกิน h*ts เป็นปกติ ห้ามถือ ty>=h เป็น “ซีลพื้นแมป” ไม่งั้น solid+กำแพงข้าง = ตายคาแอร์ */
+ if (ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!pa[ty] || pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ /* ceil/floor กับช่วงสั้น ๆ อาจได้ kMin > kMax → ไม่ลอง k เลย แล้วตกทะลุแพลตฟอร์ม */
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** แถวแพลตฟอร์มบนสุด/ล่างสุดของแมป (มีช่องอย่างน้อยหนึ่งช่อง) — ใช้เป็นเพดาน/พื้นตาย */
+ function jumpSurvivePlatformEdgeKillRows(md) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa) return null;
+ const h = Math.min(pa.length, md.height || pa.length || 15);
+ const wM = md.width || 20;
+ let minTy = h;
+ let maxTy = -1;
+ for (let ty = 0; ty < h; ty++) {
+ const row = pa[ty];
+ if (!row) continue;
+ const wlim = Math.min(row.length || 0, wM);
+ let rowHas = false;
+ for (let tx = 0; tx < wlim; tx++) {
+ if (row[tx] === 1) {
+ rowHas = true;
+ break;
+ }
+ }
+ if (!rowHas) continue;
+ if (ty < minTy) minTy = ty;
+ if (ty > maxTy) maxTy = ty;
+ }
+ if (minTy > maxTy) return null;
+ if (minTy === maxTy) return null;
+ return { minTy, maxTy };
+ }
+
+ /** ชนแพลตฟอร์มแถวเพดานเท่านั้น (รวมเลื่อนแบบคาบ) = ตายทันที — ไม่ฆ่าที่แถวพื้นล่าง */
+ function jumpSurviveOverlapsEdgeKillPlatforms(md, left, top, right, bottom, scrollPx) {
+ const edges = jumpSurvivePlatformEdgeKillRows(md);
+ if (!edges) return false;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ const overlapsRowTy = function (ty) {
+ if (ty < 0 || ty >= h || !pa[ty]) return false;
+ for (let tx = x0; tx <= x1; tx++) {
+ if (pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ const zSlack = ts * 0.08;
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top - zSlack && platTop < bottom + zSlack && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ return false;
+ };
+ if (overlapsRowTy(edges.minTy)) return true;
+ return false;
+ }
+
+ function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /**
+ * ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย
+ * (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน)
+ */
+ function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ const ts = tileSize;
+ const footSlop = ts * 0.16;
+ const platformBottom = bottom - footSlop;
+ if (platformBottom <= top + ts * 0.05) return false;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
+ function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
+ jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
+ }
+
+ function jumpSurviveCameraTargetPx(md) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 };
+ }
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = (e) => (e.x + cw * 0.5) * ts;
+ const cy = (e) => (e.y + ch * 0.5) * ts;
+ if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
+ const aliveHumanIds = [...others.keys()].filter((id) => {
+ if (isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const aliveBotIds = [...others.keys()].filter((id) => {
+ if (!isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
+ if (!useIds.length) return { px: cx(me), py: cy(me) };
+ let sx = 0, sy = 0;
+ useIds.forEach((id) => {
+ const o = others.get(id);
+ if (!o) return;
+ sx += cx(o);
+ sy += cy(o);
+ });
+ const n = useIds.length;
+ return { px: sx / n, py: sy / n };
+ }
+
+ /** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */
+ function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) {
+ if (!wishDir) return true;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const probeX = pxc + wishDir * (bw + ts * 0.82);
+ const pad = ts * 0.24;
+ const left = probeX - pad;
+ const right = probeX + pad;
+ const top = feetY - ts * 0.45;
+ const bottom = feetY + ts * 8;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx);
+ }
+
+ function stepJumpSurvivePreviewBots(dt, halfViewH) {
+ if (!playBotsEnabled() || !mapData || !isJumpSurvive()) return;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ts = tileSize;
+ const wpx = (md.width || 20) * ts;
+ const centerPx = wpx * 0.5;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (o.jumpSurviveEliminated) return;
+ if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
+ if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
+ if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
+ if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
+ if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
+
+ let pxc = (o.x + cw * 0.5) * ts;
+ let feetY = (o.y + ch) * ts;
+
+ const camCy = jumpSurviveCamCenterY;
+ const mission = isJumpSurviveMissionUiMapPlay();
+ const lowDanger = feetY > camCy + halfViewH - ts * 8.5;
+
+ if (Date.now() >= o.jumpSurviveWishNext) {
+ const wMin = mission && lowDanger ? 180 : 380;
+ const wSpan = mission && lowDanger ? 480 : 700;
+ o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan);
+ const tier = o.botTier || 'avg';
+ if (mission && lowDanger && Math.random() < 0.55) {
+ o.jumpSurviveWishX = 0;
+ } else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
+ else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
+ else if (tier === 'sharp') {
+ const roam = mission ? 0.42 : 0.55;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ } else {
+ const roam = mission ? 0.28 : 0.4;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ }
+ }
+
+ let moveLeft = o.jumpSurviveWishX < 0;
+ let moveRight = o.jumpSurviveWishX > 0;
+ let hazardJump = false;
+ if (o.jumpSurviveOnGround) {
+ const sc = jumpSurvivePlatformScrollPx;
+ if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0;
+ hazardJump = true;
+ } else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0;
+ hazardJump = true;
+ }
+ moveLeft = o.jumpSurviveWishX < 0;
+ moveRight = o.jumpSurviveWishX > 0;
+ }
+
+ let jumpQ = false;
+ if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now());
+ if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
+ o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
+ const tier = o.botTier || 'avg';
+ let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
+ if (mission) jp += 0.12;
+ if (lowDanger) jp += 0.22;
+ jp = Math.min(0.9, jp);
+ if (hazardJump || Math.random() < jp) jumpQ = true;
+ }
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: o.jumpSurviveVy,
+ wasOnGround: o.jumpSurviveOnGround,
+ jumpQueued: jumpQ,
+ moveLeft,
+ moveRight,
+ });
+
+ if (r.died) {
+ o.jumpSurviveEliminated = true;
+ o.jumpSurviveVy = 0;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ o.botIsWalking = false;
+ return;
+ }
+ o.jumpSurviveVy = r.vy;
+ o.jumpSurviveOnGround = r.onGround;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (moveRight) o.direction = 'right';
+ else if (moveLeft) o.direction = 'left';
+ o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
+ });
+ }
+
+ function jumpSurviveTickFrame() {
+ if (!mapData || !isJumpSurvive()) return;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const remEnd = jumpSurviveRemainingSecMission();
+ if (remEnd !== null && remEnd <= 0) {
+ endJumpSurviveMissionRound('time_up');
+ return;
+ }
+ }
+ const md = mapData;
+ const ts = tileSize;
+ const now = performance.now();
+ const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
+ jumpSurviveLastTickMs = now;
+
+ let zJumpSurviveWorld = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul;
+ const halfViewH = canvas.height / (2 * zJumpSurviveWorld);
+ const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
+ jumpSurvivePlatformScrollPx += rise;
+
+ const camTgt = jumpSurviveCameraTargetPx(md);
+ const ck = Math.min(1, dt * 7);
+ jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
+ jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
+
+ if (jumpSurviveEliminated) {
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+ jumpSurviveMissionMaybeEarlyFinish();
+ return;
+ }
+
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let pxc = (me.x + cw * 0.5) * ts;
+ let feetY = (me.y + ch) * ts;
+
+ const jq = jumpSurviveJumpQueued;
+ jumpSurviveJumpQueued = false;
+
+ /* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */
+ const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0;
+ const moveLeftIn = !!(keys['ArrowLeft'] || keys['KeyA']) || joyX < -0.001;
+ const moveRightIn = !!(keys['ArrowRight'] || keys['KeyD']) || joyX > 0.001;
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: jumpSurviveVy,
+ wasOnGround: jumpSurviveOnGround,
+ jumpQueued: jq,
+ moveLeft: moveLeftIn,
+ moveRight: moveRightIn,
+ });
+
+ if (r.died) {
+ jumpSurviveEliminated = true;
+ jumpSurviveVy = 0;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
+ } else {
+ jumpSurviveVy = r.vy;
+ jumpSurviveOnGround = r.onGround;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ }
+
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+
+ if (!jumpSurviveEliminated) {
+ me.isWalking = Math.abs(jumpSurviveVy) > 40 || moveLeftIn || moveRightIn;
+ if (moveRightIn) me.direction = 'right';
+ else if (moveLeftIn) me.direction = 'left';
+
+ if (Date.now() - jumpSurviveLastEmitT > 80) {
+ jumpSurviveLastEmitT = Date.now();
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+
+ jumpSurviveMissionMaybeEarlyFinish();
+ }
+
+ function gridImageLibIdleUrlPlay(entry) {
+ if (entry == null) return '';
+ if (typeof entry === 'string') return entry;
+ if (typeof entry === 'object' && entry.idle) return String(entry.idle);
+ return '';
+ }
+ function gridImageLibHeldUrlPlay(entry) {
+ if (entry == null || typeof entry === 'string') return '';
+ if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
+ return '';
+ }
+
+ function normalizeGridImageCellsOnMap(md) {
+ if (!md) return;
+ const w = md.width || 20, h = md.height || 15;
+ if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
+ md.gridImageLibrary = md.gridImageLibrary
+ .map((raw) => {
+ if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null };
+ if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) {
+ let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null;
+ if (held && held === raw.idle) held = null;
+ return { idle: raw.idle, held };
+ }
+ return null;
+ })
+ .filter(Boolean);
+ const libLen = md.gridImageLibrary.length;
+ let placements = [];
+ if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
+ placements = md.gridImageSprites.map((raw) => {
+ const i = Math.max(0, Math.floor(Number(raw.i)));
+ const x = Math.floor(Number(raw.x));
+ const y = Math.floor(Number(raw.y));
+ const ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
+ const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
+ const rawB = raw.bindCarryOption;
+ const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ const base = { i, x, y, w: ww, h: hh };
+ if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn;
+ return base;
+ }).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
+ Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
+ s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ placements = placements.map((s) => {
+ let x = s.x, y = s.y, ww = s.w, hh = s.h;
+ if (x < 0) { ww += x; x = 0; }
+ if (y < 0) { hh += y; y = 0; }
+ if (x + ww > w) ww = w - x;
+ if (y + hh > h) hh = h - y;
+ const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
+ if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption;
+ return out;
+ }).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ } else {
+ const cells = md.gridImageCells;
+ if (Array.isArray(cells) && cells.length === h && libLen > 0) {
+ for (let yy = 0; yy < h; yy++) {
+ const row = cells[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] == null) continue;
+ const iv = parseInt(row[xx], 10);
+ if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
+ placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
+ }
+ }
+ }
+ }
+ md.gridImagePlacements = placements;
+ const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
+ placements.forEach((sp) => {
+ for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
+ for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
+ if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
+ }
+ }
+ });
+ md.gridImageCells = outCells;
+ }
+
+ function loadMapGridImages() {
+ mapGridImageImgs = [];
+ mapGridImageHeldImgs = [];
+ if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
+ mapData.gridImageLibrary.forEach((entry, i) => {
+ mapGridImageImgs[i] = null;
+ mapGridImageHeldImgs[i] = null;
+ const idle = gridImageLibIdleUrlPlay(entry);
+ if (!idle) return;
+ const im = new Image();
+ im.onload = () => { try { draw(); } catch (e) {} };
+ im.onerror = () => {};
+ im.src = idle;
+ mapGridImageImgs[i] = im;
+ const held = gridImageLibHeldUrlPlay(entry);
+ if (held && held !== idle) {
+ const hm = new Image();
+ hm.onload = () => { try { draw(); } catch (e) {} };
+ hm.onerror = () => {};
+ hm.src = held;
+ mapGridImageHeldImgs[i] = hm;
+ }
+ });
+ }
+
+ /** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */
+ function dominantQuizCarryOptionInSpriteRect(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null;
+ const g = md.quizCarryOptionArea;
+ const mw = md.width || 20, mh = md.height || 15;
+ const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0;
+ const counts = new Map();
+ function addOverlapRect(x0, y0, ww, hh) {
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ const v = row[xx];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue;
+ const idx = n - 1;
+ counts.set(idx, (counts.get(idx) || 0) + 1);
+ }
+ }
+ }
+ addOverlapRect(bx, by, bw, bh);
+ if (!counts.size) {
+ const pad = 1;
+ const x0 = Math.max(0, bx - pad);
+ const y0 = Math.max(0, by - pad);
+ const x1 = Math.min(mw, bx + bw + pad);
+ const y1 = Math.min(mh, by + bh + pad);
+ addOverlapRect(x0, y0, x1 - x0, y1 - y0);
+ }
+ if (!counts.size) return null;
+ let best = null;
+ let bestN = -1;
+ counts.forEach((n, k) => {
+ if (best == null || n > bestN || (n === bestN && k < best)) {
+ bestN = n;
+ best = k;
+ }
+ });
+ return best;
+ }
+
+ /** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */
+ function quizCarryOptionIndexForGridSprite(md, sp) {
+ if (!sp) return null;
+ const rawB = sp.bindCarryOption;
+ if (rawB != null && rawB !== '') {
+ const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1;
+ }
+ return dominantQuizCarryOptionInSpriteRect(md, sp);
+ }
+
+ /** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */
+ function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) return gh;
+ }
+ return null;
+ }
+
+ /** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */
+ function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gid = mapGridImageImgs[sp.i];
+ if (gid && gid.complete && gid.naturalWidth) return gid;
+ }
+ return null;
+ }
+
+ function applyMapAndStart(gameMapData, res) {
+ const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
+ const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
+ mapData = gameMapData;
+ playEmbedUserZoomMul = 1;
+ if (mapData.gameType !== 'quiz_carry') {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ }
+ {
+ const qMap = (playMapIdFromQuery || '').trim();
+ const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : '';
+ const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : '';
+ if (qMap) playSessionMapId = qMap;
+ else if (dataId) playSessionMapId = dataId;
+ else if (resMap) playSessionMapId = resMap;
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (res && res.hostId != null) playHostId = res.hostId;
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ if (prevWasSpaceShooter) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ spaceShooterMissionPhase = null;
+ spaceShooterAsteroidExplosions = [];
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccLeave = document.getElementById('gauntlet-crown-countdown');
+ if (gccLeave) gccLeave.classList.add('is-hidden');
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ if (prevWasBalloonBoss) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ }
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ if (!mapData.interactive) mapData.interactive = [];
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
+ if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
+ if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = [];
+ if (!mapData.quizQuestions) mapData.quizQuestions = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ normalizeQuizCarryLayersInPlay(mapData);
+ applyQuizCarryWalkSpeedFromSettingsObj(quizCarryJoinSettingsSnap || {});
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ }
+ tileSize = mapData.tileSize || 32;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ const plist = Array.isArray(res.peers) ? res.peers : [];
+ const myPeer = plist.find(p => p.id === myId);
+ const myPos = peerXYFromJoin(myPeer, mapData.spawn);
+ me.x = myPos.x;
+ me.y = myPos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
+ me.x = hubSnap.x;
+ me.y = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = pathSnap.x;
+ me.y = pathSnap.y;
+ }
+ me.direction = myPeer?.direction ?? 'down';
+ if (myPeer && myPeer.nickname) {
+ me.nickname = String(myPeer.nickname).trim() || me.nickname;
+ nick = me.nickname;
+ }
+ me.characterId = myPeer?.characterId || null;
+ meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ {
+ const sc0 = Number(myPeer && myPeer.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
+ me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated);
+ }
+ {
+ const sh0 = Number(myPeer && myPeer.spaceShooterScore);
+ me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
+ }
+ {
+ const bSt = balloonBossBalloonsStartPlay();
+ const bs0 = Number(myPeer && myPeer.balloonBossScore);
+ me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
+ const bd0 = Number(myPeer && myPeer.balloonBossBossDmg);
+ me.balloonBossBossDmg = Number.isFinite(bd0) ? Math.max(0, bd0) : 0;
+ const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
+ me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
+ me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
+ }
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes;
+ const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId);
+ me.playTint = playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin);
+ if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) {
+ try {
+ const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex);
+ const si = parsePlayLobbySkinIndex(myPeerJoin.lobbySkinToneIndex) || 1;
+ if (ti) {
+ localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({
+ themeRgb: PLAY_LOBBY_THEME_HEX[ti - 1],
+ skinRgb: PLAY_LOBBY_SKIN_HEX[si - 1],
+ }));
+ }
+ } catch (eTintStore) { /* ignore */ }
+ }
+ // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม
+ try {
+ preloadPlayCharacterLayers(me.characterId || getPlayCharacterId());
+ (Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); });
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ {
+ const jo = Number(myPeer && myPeer.spawnJoinOrder);
+ me.spawnJoinOrder = Number.isFinite(jo) ? Math.max(0, Math.floor(jo)) : Math.max(0, plist.findIndex((q) => q && q.id === myId));
+ }
+ others.clear();
+ plist.forEach(p => {
+ if (p.id !== myId) {
+ const pos = peerXYFromJoin(p, mapData.spawn);
+ let px = pos.x, py = pos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
+ px = hubSnap.x;
+ py = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ px = pathSnap.x;
+ py = pathSnap.y;
+ }
+ const joP = Number(p && p.spawnJoinOrder);
+ const spawnOrd = Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : Math.max(0, plist.findIndex((q) => q && q.id === p.id));
+ others.set(p.id, {
+ x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
+ spawnJoinOrder: spawnOrd,
+ playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p),
+ gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ gauntletEliminated: !!p.gauntletEliminated,
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(p.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(p.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!p.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ }
+ });
+ if (mapData.gameType === 'gauntlet') {
+ me.tx = me.x;
+ me.ty = me.y;
+ if (res.gauntletEndsAt != null) {
+ const geJoin = Number(res.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ } else {
+ me.tx = null;
+ me.ty = null;
+ gauntletEndsAtMs = null;
+ }
+ rebalancePreviewBots();
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ applyQuizBattlePreviewSpawnLayout(false);
+ try { socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (eSync) { /* ignore */ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (isStack()) {
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 50);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ playPath = [];
+ me.isWalking = false;
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 50);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ }
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 50);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR = document.getElementById('gauntlet-crown-countdown');
+ if (gccR) gccR.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRm = document.getElementById('gauntlet-crown-countdown');
+ if (gccRm) gccRm.classList.add('is-hidden');
+ if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld });
+ }
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) {
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRj = document.getElementById('gauntlet-crown-countdown');
+ if (gccRj) gccRj.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccRs = document.getElementById('gauntlet-crown-countdown');
+ if (gccRs) gccRs.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gccRq = document.getElementById('gauntlet-crown-countdown');
+ if (gccRq) gccRq.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const gccRst = document.getElementById('gauntlet-crown-countdown');
+ if (gccRst) gccRst.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hov) {
+ hov.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR2 = document.getElementById('gauntlet-crown-countdown');
+ if (gccR2) gccR2.classList.add('is-hidden');
+ teardownStackTowerMissionUiPlay();
+ }
+ resizeCanvas(); draw(); tick();
+ }
+
+ socket.on('connect', () => {
+ firstCharacterDefaultPromise.then(() => {
+ const joinPlayMapId = (params.get('map') || '').trim();
+ const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null)
+ || readStoredDisplayName()
+ || nick;
+ socket.emit('join-space', {
+ spaceId,
+ nickname: joinNick,
+ characterId: getPlayCharacterId(),
+ playMapId: joinPlayMapId || undefined,
+ playerKey: getJdPlayerKey(),
+ }, (res) => {
+ if (!res || !res.ok) {
+ const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
+ alert(errMsg);
+ const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
+ if (caseLocked) {
+ window.location.replace(buildRoomLobbyReturnHref());
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ return;
+ }
+ myId = socket.id;
+ if (specialQuizForceDebug) {
+ try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ }
+ }
+ if (__isTestModeOnPlay()) {
+ try { socket.emit('special-quiz-testmode', { on: true }); } catch (eT) { /* ignore */ }
+ }
+ let botSlots = parseInt(res.botSlotCount, 10);
+ if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
+ botSlots = 6 - res.maxPlayers;
+ }
+ if (botSlots > 0) {
+ detectiveCaseFillBots = true;
+ detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots));
+ }
+ if (isDetectiveMinigamePlay()) {
+ try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ }
+ }
+ if (res.quizSettingsSnap && typeof res.quizSettingsSnap === 'object') {
+ if (res.quizSettingsSnap.quizMapPanelTheme && typeof res.quizSettingsSnap.quizMapPanelTheme === 'object') {
+ setQuizMapPanelThemeFromApi({ quizMapPanelTheme: res.quizSettingsSnap.quizMapPanelTheme });
+ }
+ }
+ if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') {
+ quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap;
+ if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') {
+ setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme });
+ }
+ if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') {
+ setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme });
+ }
+ if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes });
+ } else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme });
+ }
+ const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(joinSc)) {
+ quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc));
+ }
+ }
+ if (res.specialQuizIcon) setSpecialQuizIconFromServer(res.specialQuizIcon);
+ if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
+ const q = [];
+ q.push('space=' + encodeURIComponent(spaceId));
+ q.push('nick=' + encodeURIComponent(nick));
+ const mid = params.get('map');
+ if (mid) q.push('map=' + encodeURIComponent(mid));
+ q.push('preview=1');
+ q.push('editorEmbed=1');
+ if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
+ window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
+ return;
+ }
+ const playMapId = params.get('map');
+ if (playMapId) {
+ fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId))
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data) applyMapAndStart(data, res);
+ else applyMapAndStart(res.mapData, res);
+ })
+ .catch(() => applyMapAndStart(res.mapData, res));
+ } else {
+ applyMapAndStart(res.mapData, res);
+ }
+ });
+ });
+ });
+
+ const LERP = 0.2;
+
+ function applyPlayDisplayNameUpdate(data) {
+ if (!data || !data.id) return;
+ const next = String(data.nickname || '').trim();
+ if (!next) return;
+ if (data.id === myId) {
+ me.nickname = next;
+ nick = next;
+ try {
+ localStorage.setItem('roomLobbyDisplayName', next);
+ localStorage.setItem('playerName', next);
+ } catch (e) { /* ignore */ }
+ if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') {
+ window.jdDisplayName.syncDom(next);
+ }
+ } else {
+ const o = others.get(data.id);
+ if (o) o.nickname = next;
+ }
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (typeof syncPlayCyberHud === 'function') syncPlayCyberHud();
+ }
+
+ socket.on('peer-display-name', applyPlayDisplayNameUpdate);
+
+ socket.on('lobby-tint-sync', applyPlayLobbyTintSync);
+
+ window.addEventListener('jd-display-name-changed', function (ev) {
+ const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : '';
+ if (!next || !myId) return;
+ applyPlayDisplayNameUpdate({ id: myId, nickname: next });
+ });
+
+ socket.on('user-joined', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ const x = Number(data.x);
+ const y = Number(data.y);
+ const px = Number.isFinite(x) ? x : 1;
+ const py = Number.isFinite(y) ? y : 1;
+ const joJ = Number(data && data.spawnJoinOrder);
+ const spawnOrdJ = Number.isFinite(joJ) ? Math.max(0, Math.floor(joJ)) : 0;
+ others.set(data.id, {
+ x: px, y: py, tx: px, ty: py,
+ direction: data.direction || 'down', nickname: data.nickname,
+ characterId: data.characterId ?? null,
+ spawnJoinOrder: spawnOrdJ,
+ playTint: playTintFromJoinPeer(data) || playTintForPreviewBotId(data.id) || resolvePlayTintForPeer(data.id, data),
+ gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(data.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(data.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!data.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('host-changed', (d) => {
+ if (d && d.hostId != null) playHostId = d.hostId;
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('quiz-carry-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ quizCarryLobbyReadyMap = { ...d.readyMap };
+ if (quizCarryPregameActive && isQuizCarry()) {
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+ });
+ socket.on('quiz-carry-lobby-started', () => {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ endQuizCarryEmbedPregameAndStart();
+ });
+ socket.on('gauntlet-crown-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ gauntletCrownLobbyReadyMap = { ...d.readyMap };
+ if (gauntletCrownPregamePhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ } else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ } else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ } else if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ } else if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ });
+ socket.on('gauntlet-crown-lobby-started', () => {
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ beginGauntletCrownCountdownThenRun();
+ });
+ socket.on('user-move', (data) => {
+ if (data && myId != null && data.id === myId) {
+ /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
+ if (mapData && mapData.gameType === 'gauntlet') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'stack') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'jump_survive') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'space_shooter') {
+ if (data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
+ }
+ return;
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) me.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
+ return;
+ }
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — เพิกเฉย echo ตำแหน่งของตัวเอง (กันเด้งกลับ) */
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) me.x = x;
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) me.y = y;
+ }
+ const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = s.x;
+ me.y = s.y;
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) {
+ me.x = x;
+ me.tx = me.x;
+ }
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) {
+ me.y = y;
+ me.ty = me.y;
+ }
+ }
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ const o = others.get(data.id);
+ if (o) {
+ if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px) && Number.isFinite(py)) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ o.tx = sn.x;
+ o.ty = sn.y;
+ o.x = sn.x;
+ o.y = sn.y;
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ }
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ if (mapData && isQuiz()) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px)) o.x = px;
+ if (Number.isFinite(py)) o.y = py;
+ }
+ }
+ o.direction = data.direction || o.direction;
+ if (data.characterId != null) o.characterId = data.characterId;
+ if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) o.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
+ }
+ }
+ });
+ socket.on('user-left', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ others.delete(data.id);
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('chat', (data) => {
+ const box = document.getElementById('chat-messages');
+ if (!box) return;
+ const div = document.createElement('div');
+ div.className = 'chat-msg';
+ div.textContent = (data.nickname || '') + ': ' + (data.text || '');
+ box.appendChild(div);
+ box.scrollTop = 1e9;
+ });
+
+ socket.on('quiz-phase', (p) => {
+ if (previewMode) return;
+ if (!p || !mapData || !isQuiz()) return;
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') {
+ quizQuestionMissionDeferredPhase = p;
+ return;
+ }
+ applyQuizPhaseFromServer(p);
+ });
+
+ socket.on('quiz-result', (r) => {
+ if (previewMode || !r) return;
+ if (r.scores) {
+ playLiveQuizScores = { ...r.scores };
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ if (isQuiz() && Array.isArray(r.results)) {
+ r.results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) {
+ playQuizEverWrong[String(row.id)] = true;
+ const o = others.get(row.id);
+ if (o) {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ }
+ }
+ });
+ }
+ if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ });
+
+ socket.on('quiz-player-state', (st) => {
+ if (previewMode) return;
+ if (!st) return;
+ playQuizPlayerLocal = {
+ cannotTrue: !!st.cannotTrue,
+ cannotFalse: !!st.cannotFalse,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
+ };
+ if (quizGhostConfineActivePlay()) warpMeIntoQuizGhostZonePlay();
+ });
+
+ socket.on('quiz-ended', () => {
+ if (previewMode && isQuiz()) return;
+ let questionMissionEnd = null;
+ if (isQuizQuestionMissionUiMapPlay()) {
+ questionMissionEnd = quizQuestionMissionBuildPayload();
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizPhaseLocal = null;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ }
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playLiveQuizScores = {};
+ hidePlayQuizHud();
+ if (questionMissionEnd) {
+ quizQuestionMissionPhase = 'ended';
+ quizQuestionMissionDeferredPhase = null;
+ applyQuizQuestionMissionPanelImages();
+ showGauntletCrownMissionOverlay(questionMissionEnd);
+ }
+ });
+
+ /* ===== Special Quiz ในเกม — socket events ===== */
+ socket.on('special-quiz-icon', (payload) => {
+ if (previewMode) return;
+ setSpecialQuizIconFromServer(payload);
+ });
+ socket.on('special-quiz-icon-clear', () => { clearSpecialQuizIcon(); });
+ socket.on('special-quiz-question', (q) => {
+ if (previewMode) return;
+ openSpecialQuizQuestion(q);
+ });
+ socket.on('special-quiz-answer-update', (d) => {
+ if (previewMode || !d || !specialQuizActiveQuestion) return;
+ markSpecialQuizMemberAnswered(d.id);
+ });
+ socket.on('special-quiz-result', (r) => {
+ if (previewMode) return;
+ handleSpecialQuizResult(r);
+ });
+ socket.on('special-quiz-ended', (data) => {
+ if (previewMode) return;
+ endSpecialQuiz(data);
+ });
+ socket.on('special-card-applied', (data) => {
+ if (previewMode || !data || !data.card) return;
+ applySpecialCardClient(data);
+ });
+
+ socket.on('gauntlet-sync', (data) => {
+ if (!data || typeof data !== 'object') return;
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyGauntletTimingFromServer(data);
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ applyGauntletTimingFromServer(data);
+ if (Array.isArray(data.obstacles)) {
+ gauntletObstacles = data.obstacles;
+ pushGauntletObsRenderFrame(data.obstacles);
+ }
+ if (!Array.isArray(data.players)) return;
+ data.players.forEach((p) => {
+ if (!p || p.id == null || myId == null) return;
+ const pid = String(p.id);
+ const mid = String(myId);
+ if (pid === mid) {
+ const prevSc = Math.max(0, Number(me.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) me.tx = px;
+ if (Number.isFinite(py)) me.ty = py;
+ if (p.direction) me.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ me.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(me);
+ } else {
+ const o = others.get(p.id) ?? others.get(pid);
+ if (o) {
+ const prevSc = Math.max(0, Number(o.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) o.tx = px;
+ if (Number.isFinite(py)) o.ty = py;
+ if (p.direction) o.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(o);
+ }
+ }
+ });
+ if (playBotsEnabled()) {
+ applyGauntletPreviewBotsAfterSync();
+ emitGauntletPreviewRowsToServer();
+ }
+ });
+
+ function showMinigameCoinsToast(mine) {
+ let el = document.getElementById('minigame-coins-toast');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'minigame-coins-toast';
+ el.style.cssText = 'position:fixed;left:50%;top:13%;transform:translateX(-50%);z-index:99999;padding:13px 26px;border-radius:14px;font-weight:700;text-align:center;pointer-events:none;box-shadow:0 6px 24px rgba(0,0,0,.45);transition:opacity .35s;opacity:0;font-size:20px;line-height:1.3;';
+ document.body.appendChild(el);
+ }
+ const survived = mine.survived !== false;
+ const coins = Math.max(0, Number(mine.coins) || 0);
+ if (survived && coins > 0) {
+ el.style.background = 'linear-gradient(135deg,#1b9e4b,#13c06a)';
+ el.style.color = '#fff';
+ el.innerHTML = '🪙 +' + coins + ' เหรียญคะแนนสะสม +' + coins + ' ';
+ } else if (survived) {
+ el.style.background = 'linear-gradient(135deg,#555,#777)';
+ el.style.color = '#fff';
+ el.textContent = 'รอบนี้ไม่ติดอันดับรางวัล';
+ } else {
+ el.style.background = 'linear-gradient(135deg,#a11,#d33)';
+ el.style.color = '#fff';
+ el.textContent = '💀 ตายแล้ว — ไม่ได้รับเหรียญ';
+ }
+ el.style.opacity = '1';
+ if (window.__miniCoinsToastT) clearTimeout(window.__miniCoinsToastT);
+ window.__miniCoinsToastT = setTimeout(() => { el.style.opacity = '0'; }, 4200);
+ }
+
+ socket.on('minigame-coins', (data) => {
+ try {
+ if (!data || !Array.isArray(data.results)) return;
+ const mine = data.results.find((r) => r.id === myId);
+ if (mine) showMinigameCoinsToast(mine);
+ } catch (e) { /* ignore */ }
+ });
+
+ socket.on('gauntlet-ended', (data) => {
+ cancelQuizCarryResultEndAfterTimeup();
+ gauntletEndsAtMs = null;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ gauntletCrownRunHeldRemote = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ if (data && data.crownMission) {
+ showGauntletCrownMissionOverlay(data.crownMission);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
+ }
+ msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
+ listEl.innerHTML = '';
+ const ranks = (data && data.rankings) || [];
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ });
+
+ socket.on('game-start', (ev) => {
+ if (!ev || !ev.mapId) return;
+ const urlMap = (params.get('map') || '').trim();
+ if (urlMap && ev.stayInRoomLobby && String(ev.mapId).trim() !== urlMap) {
+ return;
+ }
+ const applySnap = (md) => {
+ mapData = md;
+ playEmbedUserZoomMul = 1;
+ if (ev.mapId != null && String(ev.mapId).trim() !== '') {
+ playSessionMapId = String(ev.mapId).trim();
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (!isStackTowerMissionUiMapPlay()) {
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ spaceShooterMissionPhase = null;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ tileSize = mapData.tileSize || 32;
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
+ ev.peersSnap.forEach((p) => {
+ if (p.id != null && myId != null && String(p.id) === String(myId)) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { me.x = px; me.tx = px; }
+ if (Number.isFinite(py)) { me.y = py; me.ty = py; }
+ me.direction = p.direction || me.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ } else {
+ const o = others.get(p.id) ?? others.get(String(p.id));
+ if (o) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { o.x = px; o.tx = px; }
+ if (Number.isFinite(py)) { o.y = py; o.ty = py; }
+ o.direction = p.direction || o.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ }
+ }
+ });
+ } else {
+ meGauntletJumpTicks = 0;
+ meGauntletJumpVis = 0;
+ me.gauntletScore = 0;
+ me.gauntletEliminated = false;
+ others.forEach((o) => {
+ o.gauntletJumpTicks = 0;
+ o.gauntletJumpVis = 0;
+ o.gauntletScore = 0;
+ o.gauntletEliminated = false;
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ if (ev.gauntletEndsAt != null) {
+ const ge = Number(ev.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc0 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc0) gcc0.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ const hto2 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto2) hto2.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc1 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc1) gcc1.classList.add('is-hidden');
+ }
+ } else {
+ gauntletEndsAtMs = null;
+ const megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID;
+ const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay())
+ || (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay())
+ || (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay())
+ || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay())
+ || megaShellGs;
+ if (!skipHideCrownShell) {
+ const hto3 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto3) hto3.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc2 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc2) gcc2.classList.add('is-hidden');
+ }
+ if (mapData.gameType !== 'gauntlet') {
+ me.tx = null;
+ me.ty = null;
+ }
+ const gauntletOv = document.getElementById('gauntlet-ended-overlay');
+ if (gauntletOv) gauntletOv.classList.add('is-hidden');
+ const gcmOv = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmOv) gcmOv.classList.add('is-hidden');
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ rebalancePreviewBots();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 60);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 60);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) {
+ balloonBossSessionStartMs = 0;
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ })
+ .catch(() => {});
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccMv = document.getElementById('gauntlet-crown-countdown');
+ if (gccMv) gccMv.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ balloonBossSessionStartMs = performance.now();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ if (quizBattlePathModeActive(mapData)) {
+ const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = ms.x;
+ me.y = ms.y;
+ others.forEach((o) => {
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ });
+ }
+ }
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 60);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ resizeCanvas();
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(true);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(true);
+ }
+ if (playBotsEnabled() && mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ };
+ fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
+ .then((r) => (r.ok ? r.json() : null))
+ .then((md) => { if (md) applySnap(md); });
+ });
+
+ function resizeCanvas() {
+ if (mapData && isStackTowerMissionUiMapPlay() && canvas) {
+ canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W);
+ canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H);
+ /* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ syncQuizCarryEmbedCountdownLayout();
+ return;
+ }
+ const vw = window.innerWidth || document.documentElement.clientWidth || 800;
+ const vh = window.innerHeight || document.documentElement.clientHeight || 600;
+ const header = document.querySelector('.game-header');
+ const headerH = playHideTopChrome() ? 0 : (header && header.offsetHeight ? header.offsetHeight : 48);
+ const stage = document.getElementById('play-canvas-stage');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const parent = stage || (canvas && canvas.parentElement);
+ let cw = canvas.clientWidth || 0;
+ let ch = canvas.clientHeight || 0;
+ if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
+ if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
+ /* จอใหญ่ / iframe embed: client บางทียัง 0 ช้ากว่า layout — ใช้ offset เป็นทางเลือก */
+ if (parent) {
+ const ow = parent.offsetWidth || 0;
+ const oh = parent.offsetHeight || 0;
+ if (ow > 80) cw = Math.max(cw, ow);
+ if (oh > 80) ch = Math.max(ch, oh);
+ }
+ /* embed: stage อาจยัง 0 แต่ stack วัดได้แล้ว — อย่าให้ fallback เป็น vw/vh เต็มจอแล้ว CSS ย่อเข้า stage = ดูชิดมุม */
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ const sh = Math.max(stackEl.clientHeight || 0, stackEl.offsetHeight || 0);
+ if (sw > 80) cw = Math.max(cw, sw);
+ if (sh > 80) ch = Math.max(ch, sh);
+ }
+ if (ch < 120) ch = Math.max(240, vh - headerH - 6);
+ if (cw < 120) {
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ cw = sw > 80 ? Math.max(320, sw) : Math.max(320, vw);
+ } else cw = Math.max(320, vw);
+ }
+ canvas.width = Math.max(320, cw);
+ canvas.height = Math.max(240, ch);
+ syncQuizCarryEmbedCountdownLayout();
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) {
+ _quizTfScaleLast = 0;
+ updateQuizTfMockScale(hud);
+ }
+ }
+
+ canvas.addEventListener('click', (e) => {
+ if (!mapData || !isStack() || isChatFocused()) return;
+ tryHumanStackDrop();
+ });
+ (function setupPlayEditorEmbedViewZoom() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const wheelZoomHandler = (e) => {
+ if (!mapData) return;
+ const embedZoom = previewMode && editorEmbedReturn;
+ const qbZoom = isQuizBattle();
+ if (!embedZoom && !qbZoom) return;
+ if (isStackTowerEmbedZoomLockedPlay()) return;
+ if (isQuizQuestionMissionHudActivePlay()) return;
+ const t = e.target;
+ if (t && typeof t.closest === 'function') {
+ if (t.closest('button, input, textarea, select, a[href]')) return;
+ }
+ /* ล้อธรรม / trackpad pinch มักไม่มี ctrl — ให้ซูมได้; Ctrl/⌘+ล้อยังใช้ได้ */
+ e.preventDefault();
+ e.stopPropagation();
+ const raw = e.deltaMode === 1 ? e.deltaY * 14 : e.deltaMode === 2 ? e.deltaY * 320 : e.deltaY;
+ const k = Math.exp(-raw * 0.002);
+ if (qbZoom) {
+ quizBattleUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * k),
+ );
+ } else {
+ playEmbedUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * k),
+ );
+ }
+ draw();
+ };
+ stack.addEventListener('wheel', wheelZoomHandler, { passive: false, capture: true });
+ })();
+
+ /* กด/แตะไอคอนควิซพิเศษ — ทำงานทุกมินิเกม (กันเคสเดินไปแตะไม่ได้ เช่น space shooter) */
+ canvas.addEventListener('pointerdown', (e) => {
+ if (trySpecialQuizClickAt(e.clientX, e.clientY)) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }, true);
+
+ canvas.addEventListener('dblclick', (e) => {
+ if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) return;
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+ const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zHit = lastPlayZDrawForInput > 0 ? lastPlayZDrawForInput : zoom;
+ const gx = (sx - canvas.width / 2) / zHit + camX;
+ const gy = (sy - canvas.height / 2) / zHit + camY;
+ const tx = Math.floor(gx / tileSize);
+ const ty = Math.floor(gy / tileSize);
+ const mw = mapData.width || 20, mh = mapData.height || 15;
+ if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
+ if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
+ const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
+ if (path.length <= 1) return;
+ playPath = path.slice(1);
+ });
+
+ function getCharacterFootprintWH(md) {
+ if (!md) return { cw: 1, ch: 1 };
+ let cw = Math.floor(Number(md.characterCellsW));
+ let ch = Math.floor(Number(md.characterCellsH));
+ const hasW = Number.isFinite(cw) && cw >= 1;
+ const hasH = Number.isFinite(ch) && ch >= 1;
+ if (hasW && hasH) {
+ return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
+ }
+ const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
+ return { cw: leg, ch: leg };
+ }
+
+ /** Quiz Battle: กล่องชน/เส้นทางตามสไปรต์ที่วาดจริง (drawAvatar ใช้ cw×1.15, ch×1.35 จากเท้า) */
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15;
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35;
+
+ function quizBattleSpriteBoundsTilesPlay(md, px, py) {
+ const s = new Set();
+ if (!md || md.gameType !== 'quiz_battle') return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = px + cw * 0.5;
+ const feetY = py + ch;
+ const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H;
+ const bottom = feetY;
+ if (left < 0 || right > w || top < 0 || bottom > h) return s;
+ const minTx = Math.floor(left);
+ const maxTx = Math.min(w - 1, Math.floor(right - 1e-6));
+ const minTy = Math.floor(top);
+ const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6));
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) s.add(tx + ',' + ty);
+ }
+ return s;
+ }
+
+ /**
+ * ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง
+ */
+ function getCharacterCollisionFootprintWH(md) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let colW = Math.floor(Number(md.characterCollisionW));
+ let colH = Math.floor(Number(md.characterCollisionH));
+ if (!Number.isFinite(colW) || colW < 1) colW = cw;
+ if (!Number.isFinite(colH) || colH < 1) colH = ch;
+ colW = Math.max(1, Math.min(cw, colW));
+ colH = Math.max(1, Math.min(ch, colH));
+ return { cw, ch, colW, colH };
+ }
+
+ function getGauntletCrownHeistMaxEntityXPlay(md) {
+ if (!md || !isGauntletCrownHeistMapPlay()) return null;
+ const w = md.width || 20;
+ const { cw } = getCharacterFootprintWH(md);
+ const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw;
+ const fullMapMax = Math.max(0, w - cw + 0.999);
+ return Math.max(0, Math.min(fullMapMax, capRight));
+ }
+ function clampGauntletCrownHeistEntityXInPlacePlay(ent) {
+ if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return;
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx == null || !Number.isFinite(mx)) return;
+ if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x));
+ if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx));
+ }
+
+ /** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
+ function clampPlayEntityFootprintToMap(oEnt, md) {
+ if (!oEnt || !md) return;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let maxX = Math.max(0, w - cw + 0.999);
+ const crownMx = getGauntletCrownHeistMaxEntityXPlay(md);
+ if (crownMx != null) maxX = Math.min(maxX, crownMx);
+ const maxY = Math.max(0, h - ch + 0.999);
+ if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
+ if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
+ oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
+ oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
+ }
+
+ /** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
+ function separateClumpedPreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const ids = [...others.keys()].filter(isPreviewBotId);
+ const minD = 0.52;
+ const pushAmt = MOVE_SPEED * 0.5;
+ for (let i = 0; i < ids.length; i++) {
+ for (let j = i + 1; j < ids.length; j++) {
+ const a = others.get(ids[i]);
+ const b = others.get(ids[j]);
+ if (!a || !b) continue;
+ let ddx = b.x - a.x, ddy = b.y - a.y;
+ let d = Math.sqrt(ddx * ddx + ddy * ddy);
+ if (d >= minD) continue;
+ if (d < 1e-5) {
+ ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
+ ddy = ((i + j) % 3) - 1;
+ d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
+ }
+ const ux = -ddx / d, uy = -ddy / d;
+ const vx = ddx / d, vy = ddy / d;
+ const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
+ const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
+ if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
+ if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
+ clampPlayEntityFootprintToMap(a, mapData);
+ clampPlayEntityFootprintToMap(b, mapData);
+ }
+ }
+ }
+
+ function quizTilesFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const maxTx = Math.min(w - 1, minTx + cw - 1);
+ const maxTy = Math.min(h - 1, minTy + ch - 1);
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) {
+ if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ /** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */
+ function playEntityFootprintContainsTile(px, py, tx, ty) {
+ return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /**
+ * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
+ * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
+ * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
+ */
+ function resolveBlockPlayerNoOverlapBoxWH(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
+ let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
+ const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
+ const hasW = Number.isFinite(w) && w >= 0;
+ const hasH = Number.isFinite(h) && h >= 0;
+ if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
+ w = Math.min(leg, cw);
+ h = Math.min(leg, ch);
+ } else {
+ if (!hasW) w = 0;
+ if (!hasH) h = 0;
+ }
+ const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
+ const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
+ return { cw, ch, boxW, boxH };
+ }
+
+ /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
+ function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - boxW) / 2);
+ const offY = minTy + (ch - boxH);
+ for (let ty = offY; ty < offY + boxH; ty++) {
+ for (let tx = offX; tx < offX + boxW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
+ return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
+ function quizTilesWallCollisionFootprintPlay(px, py) {
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ return quizBattleSpriteBoundsTilesPlay(mapData, px, py);
+ }
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - colW) / 2);
+ const offY = minTy + (ch - colH);
+ for (let ty = offY; ty < offY + colH; ty++) {
+ for (let tx = offX; tx < offX + colW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function quizAnswerTileForbiddenForLock(lock, tx, ty) {
+ if (!lock || lock.eliminated) return false;
+ if (!mapData) return false;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true;
+ if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true;
+ return false;
+ }
+
+ function quizAnswerTileForbiddenPlay(tx, ty) {
+ if (!playQuizPlayerLocal) return false;
+ return quizAnswerTileForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, tx, ty);
+ }
+
+ /** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */
+ function quizLockFootprintBlocksForLock(lock, px, py) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockFootprintBlocksPlay(px, py) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockFootprintBlocksForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, px, py);
+ }
+
+ /** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */
+ function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ const fromS = quizTilesFootprintPlay(ox, oy);
+ const toS = quizTilesFootprintPlay(nx, ny);
+ for (const k of toS) {
+ if (fromS.has(k)) continue;
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockWouldEnterForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, ox, oy, nx, ny);
+ }
+
+ function botQuizLock(o) {
+ return {
+ cannotTrue: !!(o && o.quizCannotTrue),
+ cannotFalse: !!(o && o.quizCannotFalse),
+ eliminated: false,
+ };
+ }
+
+ /** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
+ function canWalkLikeLobby(x, y, fromX, fromY) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTiles = quizTilesWallCollisionFootprintPlay(x, y);
+ if (wallTiles.size === 0) return false;
+ for (const k of wallTiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTiles.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, o] of others) {
+ if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false;
+ }
+ }
+ }
+ if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksPlay(x, y)) {
+ return false;
+ }
+ }
+ if (quizGhostConfineActivePlay() && !quizGhostFootprintInsidePlay(x, y)) return false;
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) {
+ quizBattleFlashGateBlocked(mapData, x, y, quizBattlePassedComps);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y);
+ for (const k of wallTilesB) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (wallTilesB.size === 0) return false;
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTilesB.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, peer] of others) {
+ if (o && peer === o) continue;
+ if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false;
+ }
+ if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false;
+ }
+ }
+ if (isQuiz()) {
+ const lock = botQuizLock(o);
+ if (lock.cannotTrue || lock.cannotFalse) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksForLock(lock, x, y)) {
+ return false;
+ }
+ }
+ }
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (o && isPreviewBotId(o.id || o.peerId)) { /* preview bots ไม่ล็อกประตู */ }
+ else if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) return false;
+ }
+ return true;
+ }
+
+ /** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */
+ function snapPositionOutOfQuizCarryHubIfNeeded(x, y) {
+ if (!mapData || !isQuizCarry()) return { x, y };
+ if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const maxR = Math.max(w, h) + 6;
+ for (let r = 1; r <= maxR; r++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
+ const nx = x + dx * 0.45;
+ const ny = y + dy * 0.45;
+ if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue;
+ if (quizCarryFootprintOverlapsHub(nx, ny)) continue;
+ if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue;
+ return { x: nx, y: ny };
+ }
+ }
+ }
+ return { x, y };
+ }
+
+ function stepPreviewBotAlongPath(o, w, h) {
+ const path = o.botPath;
+ if (!path || !path.length) return;
+ const way = path[0];
+ const dx = way.x - o.x, dy = way.y - o.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ path.shift();
+ while (path.length > 0) {
+ const w2 = path[0];
+ const ux = w2.x - o.x, uy = w2.y - o.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ path.shift();
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ return;
+ }
+ const len = dist || 1;
+ const carryWalk = isQuizCarry() ? quizCarryWalkSpeedMultActive : 1;
+ const baseEmbedCarryPathMul = 0.74;
+ const pathStepMul = (previewFillBots && editorEmbedReturn)
+ ? (isQuizCarry() ? baseEmbedCarryPathMul * (quizCarryWalkSpeedMultActive / QUIZ_CARRY_WALK_SPEED_MULT) : 0.56)
+ : 1;
+ const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul * carryWalk, len);
+ const nx = o.x + (dx / len) * step;
+ const ny = o.y + (dy / len) * step;
+ if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
+ else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
+ const ox = o.x, oy = o.y;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, nx, ny, (px, py, fx, fy) => canWalkLikeLobbyForBot(px, py, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ } else if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */
+ const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul * carryWalk, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
+ const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
+ const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
+ const tryAxisX = () => {
+ if (sx === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
+ o.x += sx * cstep;
+ return true;
+ };
+ const tryAxisY = () => {
+ if (sy === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
+ o.y += sy * cstep;
+ return true;
+ };
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ if (!tryAxisX()) tryAxisY();
+ } else {
+ if (!tryAxisY()) tryAxisX();
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
+ o.botIsWalking = true;
+ o.botPathStuckTicks = 0;
+ } else {
+ o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
+ if ((o.botPathStuckTicks | 0) >= 20) {
+ o.botPathStuckTicks = 0;
+ if (path.length) path.shift();
+ if (isQuizCarry() && playBotsEnabled()) o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ }
+
+ function pickRandomPreviewBotWanderDir() {
+ const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
+ return dirs[Math.floor(Math.random() * dirs.length)];
+ }
+
+ function stepPreviewBots() {
+ if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
+ if (isQuizCarry()) {
+ stepQuizCarryPreviewBots();
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const now = Date.now();
+ const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botIsWalking = false;
+ /* บอทที่ตอบผิดไปแล้วในแมป mng8a80o → เป็นผีเดินมั่ว ไม่ต้องไปยืนรอที่โซนตอบ */
+ const ghostWanderBot = isQuizQuestionMissionUiMapPlay() && isQuizWrongGhostActiveForEnt(id);
+ if (ghostWanderBot) {
+ /* ข้าม path-following → ตกไป wander logic ด้านล่าง */
+ o.botPath = [];
+ o.botAnswerWander = true;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) {
+ stepPreviewBotAlongPath(o, w, h);
+ return;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) {
+ return;
+ }
+ /* ก่อนตอบ / พัก / บอทสับสน / ผี (ตอบผิดไปแล้ว): เดินทุกเฟรมตามทิศ */
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED;
+ const tox = o.x + accX * step;
+ const toy = o.y + accY * step;
+ const ox = o.x, oy = o.y;
+ const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrictW) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, tox, toy, (nx, ny, fx, fy) => canWalkLikeLobbyForBot(nx, ny, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ if (Math.abs(o.x - ox) < 1e-5 && Math.abs(o.y - oy) < 1e-5) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ } else if (canWalkLikeLobbyForBot(tox, toy, o.x, o.y, o)) {
+ o.x = tox;
+ o.y = toy;
+ } else {
+ if (canWalkLikeLobbyForBot(tox, o.y, o.x, o.y, o)) {
+ o.x = tox;
+ } else if (canWalkLikeLobbyForBot(o.x, toy, o.x, o.y, o)) {
+ o.y = toy;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ }
+
+ /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */
+ function pathfindPlay(fromX, fromY, toX, toY) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ function pathfindPlayForBot(fromX, fromY, toX, toY, o) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ let playPath = [];
+
+ /** เลเซอร์ = รูป cut obstacles-1.png (emitter หัว + ลำแสง + emitter ท้าย ในรูปเดียว) ยืดเต็มความสูง
+ ถ้ารูปยังไม่โหลด → fallback ลำแสงแดงวาดเอง */
+ function drawGauntletLaserColumnScreen(rx, ry, rw, rh) {
+ if (rh <= 1 || rw <= 0) return;
+ const cx = rx + rw / 2;
+ const spriteRec = ensureGauntletAssetImage(BASE + '/img/gauntlet-assets/obstacles-1.png');
+ if (spriteRec && spriteRec.img.complete && spriteRec.img.naturalWidth > 0) {
+ const drawW = Math.max(14, rw * 1.5);
+ try { ctx.drawImage(spriteRec.img, cx - drawW / 2, ry, drawW, rh); } catch (e) { /* ignore */ }
+ return;
+ }
+ ctx.save();
+ const coreW = Math.max(2, rw * 0.30);
+ const glowW = Math.max(coreW + 4, rw * 0.92);
+ const prevComp = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ /* glow รอบนอก */
+ let g1 = ctx.createLinearGradient(cx - glowW / 2, 0, cx + glowW / 2, 0);
+ g1.addColorStop(0, 'rgba(255,40,60,0)');
+ g1.addColorStop(0.5, 'rgba(255,55,75,0.5)');
+ g1.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = g1;
+ ctx.fillRect(cx - glowW / 2, ry, glowW, rh);
+ /* แกนสว่าง */
+ let g2 = ctx.createLinearGradient(cx - coreW / 2, 0, cx + coreW / 2, 0);
+ g2.addColorStop(0, 'rgba(255,110,130,0.85)');
+ g2.addColorStop(0.5, 'rgba(255,240,245,0.98)');
+ g2.addColorStop(1, 'rgba(255,110,130,0.85)');
+ ctx.fillStyle = g2;
+ ctx.fillRect(cx - coreW / 2, ry, coreW, rh);
+ ctx.globalCompositeOperation = prevComp;
+ /* หัว/ท้าย emitter — รูปจริง gauntlet-dd51 (gauntletLaserTopUrl/BottomUrl) ที่บน-ล่างสุดของลำแสง
+ กล่องเป็นสีเข้ม → ใส่ glow แดงข้างหลังให้เด่นบนพื้นมืด (ผนัง museum) */
+ const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null;
+ const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null;
+ const capW = Math.max(18, rw * 2.0);
+ function laserEmitterGlow(gy) {
+ const rad = capW * 0.8;
+ const pc = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ const gg = ctx.createRadialGradient(cx, gy, 0, cx, gy, rad);
+ gg.addColorStop(0, 'rgba(255,120,140,0.9)');
+ gg.addColorStop(0.5, 'rgba(255,55,75,0.55)');
+ gg.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = gg;
+ ctx.beginPath();
+ ctx.arc(cx, gy, rad, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalCompositeOperation = pc;
+ }
+ if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) {
+ const ch = capW * topRec.img.naturalHeight / topRec.img.naturalWidth;
+ laserEmitterGlow(ry + ch * 0.35);
+ try { ctx.drawImage(topRec.img, cx - capW / 2, ry - ch * 0.1, capW, ch); } catch (e) { /* ignore */ }
+ }
+ if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) {
+ const ch = capW * botRec.img.naturalHeight / botRec.img.naturalWidth;
+ laserEmitterGlow(ry + rh - ch * 0.35);
+ /* ท้าย — กลับหัวรูป (เครื่องยิงหันขึ้น) วางชิดล่างสุด */
+ ctx.save();
+ ctx.translate(cx, ry + rh - ch * 0.9);
+ ctx.scale(1, -1);
+ try { ctx.drawImage(botRec.img, -capW / 2, 0, capW, ch); } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.restore();
+ }
+
+ /** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */
+ function drawStackPreviewCyberHud(ctx, cw, ch) {
+ if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return;
+ const now = Date.now();
+ const pad = 12;
+ const currentSeat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ const monoSm = '9px ui-monospace, Consolas, monospace';
+ const towerMapHud = isStackTowerMissionUiMapPlay();
+ /* MG3 live mission: HUD ย้ายไป DOM (#mg3-tower-hud) แล้ว — ไม่วาดบน canvas (object-fit:cover ครอปขอบหาย) */
+ if (towerMapHud) return;
+ const lifeSlots = towerMapHud
+ ? Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)))
+ : 3;
+ const livesRem = towerMapHud && stackMini.teamMissesLeft != null
+ ? Math.max(0, Number(stackMini.teamMissesLeft) || 0)
+ : Math.max(0, stackMini.lives != null ? stackMini.lives : 3);
+ const teamScore = stackMini.score || 0;
+ const combo = stackMini.combo || 0;
+
+ function roundRectPath(x, y, w, h, r) {
+ const rr = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ ctx.closePath();
+ }
+
+ /** @param {{ headBust?: boolean, headSrcFrac?: number, bustLiftPx?: number }} [avatarOpts] — headBust + bustLiftPx ดันรูปขึ้นในกรอบ */
+ function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint, avatarOpts) {
+ const timeMs = now;
+ const dir = 'down';
+ const rawImg = getAvatarImg(characterId, dir, timeMs, false);
+ const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
+ let iw = 0; let ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ const headBust = !!(avatarOpts && avatarOpts.headBust);
+ let sx = 0;
+ let sy = 0;
+ let sww = iw;
+ let shh = ih;
+ if (headBust && iw > 0 && ih > 0) {
+ const rawF = Number(avatarOpts && avatarOpts.headSrcFrac);
+ const frac = Number.isFinite(rawF) && rawF > 0.22 && rawF < 0.85 ? rawF : 0.4;
+ shh = Math.max(8, Math.round(ih * frac));
+ }
+ const ax = cx - maxSize / 2;
+ const ay = baselineY - maxSize;
+ ctx.save();
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.clip();
+ if (iw > 0 && ih > 0) {
+ const maxScale = headBust ? 2.45 : 1;
+ const scale = Math.min(maxSize / sww, maxSize / shh, maxScale);
+ const dw = sww * scale;
+ const dh = shh * scale;
+ const rawLift = headBust && avatarOpts ? Number(avatarOpts.bustLiftPx) : NaN;
+ const bustLift = headBust
+ ? (Number.isFinite(rawLift) ? Math.max(0, Math.min(maxSize * 0.45, rawLift)) : 22)
+ : 0;
+ ctx.drawImage(charImg, sx, sy, sww, shh, cx - dw / 2, baselineY - dh - bustLift, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
+ ctx.fillRect(ax, ay, maxSize, maxSize);
+ }
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.stroke();
+ }
+
+ function resolveEntryVisual(entry) {
+ if (entry.kind === 'human') {
+ return {
+ name: String(me.nickname || 'YOU').toUpperCase(),
+ score: me.stackPreviewHumanPts || 0,
+ characterId: me.characterId,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId || 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
+ score: o ? (o.stackBotScore || 0) : 0,
+ characterId: o ? o.characterId : null,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+ const boardW = Math.min(248, Math.max(200, cw * 0.36));
+ const rowH = 42;
+ const headH = 38;
+ const boardH = headH + STACK_PREVIEW_TURN_COUNT * rowH + 10;
+
+ if (!towerMapHud) {
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.stroke();
+
+ ctx.fillStyle = '#5cefff';
+ ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('SCORE', pad + 14, 16);
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
+ ctx.font = monoSm;
+ ctx.fillText('shared stack · P1–P6', pad + 62, 18);
+
+ let rowY = 8 + headH;
+ const drawRows = (entries) => {
+ entries.forEach((entry) => {
+ const isCurrent = entry.seat === currentSeat;
+ const v = resolveEntryVisual(entry);
+ let actionFlash = false;
+ if (entry.kind === 'human') {
+ actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
+ } else if (entry.botId) {
+ const ob = others.get(entry.botId);
+ actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
+ }
+ if (isCurrent) {
+ ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
+ ctx.lineWidth = 2;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ } else if (actionFlash) {
+ ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ }
+ const avS = 34;
+ drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
+ ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
+ ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = 'bold 10px ' + mono;
+ ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
+ ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
+ ctx.font = 'bold 13px ' + mono;
+ ctx.textAlign = 'right';
+ ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
+ ctx.textAlign = 'left';
+ rowY += rowH;
+ });
+ };
+
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ drawRows(order);
+ } else {
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const rows = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ drawRows(rows);
+ }
+ }
+
+ const framePad = 10;
+ const bigAv = Math.min(72, Math.max(52, ch * 0.10));
+ const avBox = bigAv + 10;
+ const integrityMinW = 120;
+ const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34)));
+ let rightX = cw - pad - rightW;
+ const topY = 8;
+ const leftReserve = towerMapHud ? (pad + 8) : (pad + boardW + 14);
+ if (rightX < leftReserve) {
+ rightX = Math.max(pad, cw - pad - rightW);
+ }
+ const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10);
+ const innerTop = topY + framePad;
+ const integrityX = rightX + framePad;
+ const integrityH = avBox + 2;
+ const avFrameX = integrityX + integrityW + 10;
+ const avFrameY = innerTop;
+ const footerH = towerMapHud ? 0 : 26;
+ const turnPanelH = framePad + integrityH + (towerMapHud ? framePad : (10 + footerH + framePad));
+
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.stroke();
+
+ let curEntry = null;
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ curEntry = order.find((e) => e.seat === currentSeat) || null;
+ }
+ const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
+
+ drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem);
+
+ const avCx = avFrameX + avBox / 2;
+ ctx.save();
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.5)';
+ ctx.shadowBlur = 12;
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)';
+ ctx.lineWidth = 2;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)';
+ ctx.lineWidth = 1;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint, towerMapHud ? { headBust: true } : undefined);
+
+ if (!towerMapHud) {
+ const footY = innerTop + integrityH + 12;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)';
+ ctx.font = monoSm;
+ ctx.fillText(
+ 'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo,
+ rightX + rightW / 2,
+ footY,
+ );
+ }
+
+ const termW = Math.min(380, Math.max(220, cw * 0.42));
+ const logLineH = 12;
+ const logMaxLines = 5;
+ const logBlockH = logMaxLines * logLineH;
+ /** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */
+ const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10);
+ const termY = Math.max((towerMapHud ? 28 : boardH + 24), ch - termH - 52);
+ ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.stroke();
+
+ ctx.fillStyle = '#c099ff';
+ ctx.font = '600 10px ' + mono;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = monoSm;
+ const logTop = termY + 24;
+ let ly = logTop;
+ const logLines = stackPreviewHudLog.length
+ ? stackPreviewHudLog.slice(-logMaxLines)
+ : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
+ logLines.forEach((ln) => {
+ ctx.fillText(ln, pad + 10, ly);
+ ly += logLineH;
+ });
+
+ const decryptPct = stackTowerDecryptPctPlay();
+ const barPadX = 10;
+ const barH = 8;
+ const barW = termW - barPadX * 2;
+ const barY = termY + termH - barH - 10;
+ const labelY = barY - 14;
+ if (labelY >= logTop + 4) {
+ ctx.fillStyle = '#94e2d5';
+ ctx.font = monoSm;
+ ctx.textBaseline = 'top';
+ ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY);
+ }
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.fillRect(pad + barPadX, barY, barW, barH);
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
+ ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH);
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
+ ctx.strokeRect(pad + barPadX, barY, barW, barH);
+
+ ctx.restore();
+ }
+
+ function useCyberPlayHud() {
+ return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()
+ || isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay()));
+ }
+
+ /** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
+ function cyberHudQuizCarryPeerScore(peerId) {
+ if (peerId == null) return 0;
+ return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
+ }
+
+ /** แมป mng8a80o ช่วงเล่น: คำถามอยู่บนแผนที่ (โซนทอง) — HUD กลางเหลือ TIME + แผ่นเวลา */
+ function syncQuizQuestionMissionCyberCenterHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const qBand = document.getElementById('play-cyber-quiz-mission-q-band');
+ const qText = document.getElementById('play-cyber-quiz-mission-q-text');
+ const qPlaque = document.getElementById('play-cyber-quiz-mission-q-plaque');
+ const tb = root ? root.querySelector('.play-cyber-time-block') : null;
+ const on = isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (qBand) {
+ qBand.classList.add('is-hidden');
+ qBand.setAttribute('aria-hidden', 'true');
+ }
+ if (qText) qText.textContent = '';
+ if (qPlaque) {
+ qPlaque.classList.add('is-hidden');
+ qPlaque.removeAttribute('src');
+ delete qPlaque.dataset.qmQsrc;
+ }
+ }
+ const plaqueImg = root ? document.getElementById('play-cyber-time-plaque-img') : null;
+ const head = tb && tb.querySelector ? tb.querySelector('.play-cyber-time-head') : null;
+ const clearQmTimePlaque = () => {
+ if (plaqueImg) {
+ plaqueImg.onload = null;
+ plaqueImg.onerror = null;
+ plaqueImg.removeAttribute('src');
+ plaqueImg.classList.add('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'true');
+ delete plaqueImg.dataset.qmTimeSrc;
+ }
+ if (head) head.classList.remove('play-cyber-time-head--qm-plaque');
+ if (tb) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ delete tb.dataset.qmTimeSrc;
+ }
+ };
+ if (tb && plaqueImg && head) {
+ const tUrl = questionMissionHudAssetUrl('time.png');
+ if (on) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ const applyPlaque = () => {
+ head.classList.add('play-cyber-time-head--qm-plaque');
+ plaqueImg.classList.remove('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'false');
+ };
+ if (plaqueImg.dataset.qmTimeSrc === tUrl && plaqueImg.getAttribute('src') === tUrl
+ && plaqueImg.complete && (plaqueImg.naturalWidth || 0) > 0) {
+ applyPlaque();
+ } else if (plaqueImg.dataset.qmTimeSrc !== tUrl || plaqueImg.getAttribute('src') !== tUrl) {
+ plaqueImg.dataset.qmTimeSrc = tUrl;
+ plaqueImg.onload = function () {
+ if ((plaqueImg.naturalWidth || 0) > 0) applyPlaque();
+ else clearQmTimePlaque();
+ };
+ plaqueImg.onerror = function () {
+ clearQmTimePlaque();
+ };
+ plaqueImg.setAttribute('src', tUrl);
+ }
+ } else {
+ clearQmTimePlaque();
+ }
+ }
+ }
+
+ function playCyberHudEsc(t) {
+ return String(t == null ? '' : t)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function resolveCyberHudAvatarTintPlay(row) {
+ if (row && row.playTint && row.playTint.head) return row.playTint;
+ const peerId = row && row.id != null ? row.id : '';
+ if (row && row.isMe) {
+ return me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me'));
+ }
+ const sid = peerId !== '' ? String(peerId) : '';
+ const ox = sid ? (others.get(peerId) || others.get(sid)) : null;
+ if (ox && ox.playTint) return ox.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ function resolveRankedCharacterIdPlay(rankedRow) {
+ if (!rankedRow) return '';
+ if (rankedRow.characterId) return String(rankedRow.characterId);
+ const id = rankedRow.id;
+ if (id == null) return '';
+ const ox = others.get(id) || others.get(String(id));
+ if (ox && ox.characterId) return String(ox.characterId);
+ if (myId != null && String(id) === String(myId)) {
+ return String(me.characterId || getPlayCharacterId() || '');
+ }
+ return '';
+ }
+
+ /** กัน cache/แสดง canvas ว่าง (ทำให้ช่องอันดับ 1 หายรูป แต่ retry หยุดเพราะ naturalWidth > 0) */
+ function playCanvasHasOpaquePixels(canvas) {
+ if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
+ try {
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ for (let i = 3; i < data.length; i += 16) {
+ if (data[i] > 12) return true;
+ }
+ } catch (e) { /* ignore */ }
+ return false;
+ }
+
+ function ensureMePlayTintFromLobbyStorage() {
+ if (me.playTint && me.playTint.head && me.playTint.body) return;
+ const t = loadPlayTintFromLobbyPlayerStorage();
+ if (t) me.playTint = t;
+ }
+
+ function cyberHudScoreAvatarCacheKey(row) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ return [row.isMe ? 'me' : String(row.id), cid, tint.head, tint.hair, tint.body, 'faceV4'].join('|');
+ }
+
+ function findPlayCyberHudScoreAvatarSrcForPeer(peerId) {
+ if (peerId == null) return '';
+ const sid = String(peerId).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return '';
+ const row = ul.querySelector('.play-cyber-score-row[data-peer-id="' + sid + '"]');
+ if (!row) return '';
+ const av = row.querySelector('img.play-cyber-score-av');
+ if (!av || !av.src || av.src === defaultAvatarImg.src) return '';
+ if (!av.complete || av.naturalWidth < 8) return '';
+ return av.src;
+ }
+
+ /** crop หน้า idle ลง (down) + tint — ใช้ทั้ง HUD และหน้าสรุปผล ให้ตรงสีในเกม */
+ function composeCyberHudFacePortraitDataUrl(cid, tint) {
+ if (!cid || !tint) return '';
+ const dir = 'down';
+ const nowT = Date.now();
+ preloadPlayTintForAvatar(cid, tint);
+ ensurePlayLayerProbesAllDirections(cid);
+ const raw = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (!raw) return '';
+ let srcCanvas = null;
+ if (raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ const composeFrame = isUploadedCharAssetId(cid) ? 0 : idleAnimPhaseIndex(nowT);
+ const packIdle = tryComposePlayLayersFromFiles(raw, cid, dir, composeFrame, tint, { idle: true });
+ if (packIdle && packIdle.canvas && playCanvasHasOpaquePixels(packIdle.canvas)) {
+ srcCanvas = packIdle.canvas;
+ }
+ if (!srcCanvas) {
+ const tinted = getPlayTintedAvatarSource(raw, cid, dir, nowT, false, tint);
+ if (tinted && tinted.tagName === 'CANVAS' && tinted.width > 0 && playCanvasHasOpaquePixels(tinted)) {
+ srcCanvas = tinted;
+ }
+ }
+ }
+ /* ตัวละครที่ไม่มีไฟล์เลเยอร์สี (เช่น defaultChar) — compose คืน Image ดิบ → crop หน้าจากรูปดิบตรง ๆ กันช่องว่าง */
+ let drawSrc = srcCanvas;
+ if (!drawSrc && raw && raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ /* ตัวที่มีไฟล์เลเยอร์สี (tintable) แต่ composite ยังไม่พร้อม หรือ API characters ยังโหลดไม่เสร็จ →
+ คืน '' ให้ applyCyberHudScoreAvatarImgWithRetry retry รอเลเยอร์โหลด (avatar bot จะไม่ถูกรับจนกว่าจะเป็น data URL ที่ย้อมสีแล้ว)
+ อย่า fallback raw ที่ "ไม่มีสี" ไม่งั้น avatar ทุกคนเหมือนกัน = "สีไม่ขึ้น"; ใช้ raw เฉพาะตัวที่ API ยืนยันว่าไม่มีเลเยอร์ (defaultChar) */
+ if (playCharLayerApi.status !== 'done' || playCharLayerFromApi(cid) === true) return '';
+ drawSrc = raw;
+ }
+ if (!drawSrc) return '';
+ const face = document.createElement('canvas');
+ face.width = 120;
+ face.height = 120;
+ const ctx = face.getContext('2d');
+ if (!ctx) return '';
+ const sw = drawSrc.naturalWidth || drawSrc.width;
+ const sh = drawSrc.naturalHeight || drawSrc.height;
+ if (!sw || !sh) return '';
+ const sx = sw * 0.1;
+ const sy = sh * 0.02;
+ const sw2 = sw * 0.8;
+ const sh2 = sh * 0.5;
+ const cropSize = Math.min(sw2, sh2);
+ const sxCenter = sx + (sw2 - cropSize) * 0.5;
+ ctx.clearRect(0, 0, 120, 120);
+ try {
+ /* crop สี่เหลี่ยมจัตุรัสจากหัว → 120×120 ไม่ยืด */
+ ctx.drawImage(drawSrc, sxCenter, sy, cropSize, cropSize, 0, 0, 120, 120);
+ } catch (e) {
+ return '';
+ }
+ if (!playCanvasHasOpaquePixels(face)) return '';
+ try {
+ return face.toDataURL('image/png');
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function cyberHudScoreAvatarImgLooksReady(img) {
+ return !!(img && img.src && img.src !== defaultAvatarImg.src && img.complete && img.naturalWidth > 8 && img.naturalHeight > 8);
+ }
+
+ /** crop หัว (จัตุรัส) จาก src เต็มตัว (lobby idle — ย้อมสีถูกแล้ว) → 120×120 data URL (async, cache)
+ * ใช้เมื่อ composeCyberHudFacePortraitDataUrl คืน '' (เช่นตัวที่ layer ไม่ compose) → human เป็น "หน้า" เหมือนบอท ไม่ใช่เต็มตัว */
+ var cyberHudLobbyFaceCropCache = new Map();
+ function cyberHudLobbyIdleHeadFaceDataUrl(src) {
+ if (!src) return '';
+ if (src.indexOf('data:') === 0 && src.length < 200) return '';
+ if (cyberHudLobbyFaceCropCache.has(src)) return cyberHudLobbyFaceCropCache.get(src);
+ cyberHudLobbyFaceCropCache.set(src, '');
+ var im = new Image();
+ im.onload = function () {
+ try {
+ var sw = im.naturalWidth, sh = im.naturalHeight;
+ if (!sw || !sh) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var c = document.createElement('canvas');
+ c.width = 120; c.height = 120;
+ var ctx = c.getContext('2d');
+ if (!ctx) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var cw = sw * 0.8, ch = sh * 0.5, cs = Math.min(cw, ch);
+ var sxC = sw * 0.1 + (cw - cs) * 0.5, syC = sh * 0.02;
+ ctx.clearRect(0, 0, 120, 120);
+ ctx.drawImage(im, sxC, syC, cs, cs, 0, 0, 120, 120);
+ cyberHudLobbyFaceCropCache.set(src, playCanvasHasOpaquePixels(c) ? c.toDataURL('image/png') : '');
+ } catch (e) { cyberHudLobbyFaceCropCache.set(src, ''); }
+ };
+ im.onerror = function () { cyberHudLobbyFaceCropCache.set(src, ''); };
+ im.src = src;
+ return '';
+ }
+
+ /** โปรไฟล์มุมขวาบน — mission mock ใช้หน้า crop สี่เหลี่ยม ไม่ดึง sprite เต็มตัว */
+ function setCyberHudProfilePortraitImg(portraitImg) {
+ if (!portraitImg) return;
+ const cid = me.characterId || getPlayCharacterId();
+ if (!cid) {
+ portraitImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const row = {
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ if (isMissionMockHudPlay()) {
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ const cacheKey = cyberHudScoreAvatarCacheKey(row) + '|profile';
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ portraitImg.src = cached;
+ return;
+ }
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ portraitImg.src = faceUrl;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ }
+ setCyberHudScoreAvatarImg(portraitImg, row);
+ }
+
+ function applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt) {
+ attempt = attempt || 0;
+ setCyberHudProfilePortraitImg(portraitImg);
+ if (cyberHudScoreAvatarImgLooksReady(portraitImg)) return;
+ if (attempt >= 16) return;
+ const cid = me.characterId || getPlayCharacterId();
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) {
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ }));
+ }
+ applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt + 1);
+ }, delay);
+ }
+
+ /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */
+ function setCyberHudScoreAvatarImg(avImg, row, skipReuse) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ if (!cid) {
+ avImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const cacheKey = cyberHudScoreAvatarCacheKey(row);
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ avImg.src = cached;
+ return;
+ }
+ /* skipReuse: ตอน retry (เช่น crop หัวยังไม่เสร็จ) อย่าอ่าน src ปัจจุบันจาก DOM กลับมา
+ ไม่งั้นจะได้ "รูปเต็มตัวชั่วคราว" กลับมาแล้ว cache ค้าง (human โชว์เต็มตัวคนเดียว) */
+ const hudSrc = skipReuse ? '' : findPlayCyberHudScoreAvatarSrcForPeer(row.id);
+ if (hudSrc) {
+ avImg.src = hudSrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, hudSrc);
+ return;
+ }
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ /* ย้อมสีตาม tint ปัจจุบันก่อนเสมอ (รวมตัวเรา) — อย่าใช้ lobby idle ต่อ characterId เพราะสีไม่ตรงที่เลือกใน lobby */
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ avImg.src = faceUrl;
+ if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) {
+ const first = cyberHudScoreAvatarUrlCache.keys().next();
+ if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value);
+ }
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ if (row.isMe) {
+ const lobbySrc = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (lobbySrc) {
+ if (isMissionMockHudPlay()) {
+ /* mock hud (mg2 ฯลฯ): crop หัวจาก lobby idle → "หน้า" จัตุรัสเหมือนบอท (กัน human โชว์เต็มตัวคนเดียว) */
+ const lobbyFace = cyberHudLobbyIdleHeadFaceDataUrl(lobbySrc);
+ if (lobbyFace) {
+ avImg.src = lobbyFace;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbyFace);
+ return;
+ }
+ /* crop ยังไม่เสร็จ (async) → ใช้ "หน้า default" ชั่วคราว (อย่าโชว์เต็มตัว — ผู้เล่นต้องเห็นแค่หัวเหมือนบอท) แล้ว retry จนได้หัวจริง
+ จำกัดจำนวน retry กัน loop ถ้า crop ล้มเหลวถาวร */
+ avImg.src = defaultAvatarImg.src;
+ var hudAvTries = avImg.__hudAvHeadTries || 0;
+ if (hudAvTries < 12) {
+ avImg.__hudAvHeadTries = hudAvTries + 1;
+ setTimeout(function () {
+ if (avImg.isConnected) setCyberHudScoreAvatarImg(avImg, row, true);
+ }, 200);
+ }
+ return;
+ }
+ avImg.src = lobbySrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbySrc);
+ return;
+ }
+ }
+ const dir = 'down';
+ const nowT = Date.now();
+ const frameIm = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (frameIm && frameIm.src) {
+ if (!frameIm.complete || !frameIm.naturalWidth) {
+ avImg.src = frameIm.src;
+ frameIm.addEventListener('load', function onHudAvFrame() {
+ frameIm.removeEventListener('load', onHudAvFrame);
+ setCyberHudScoreAvatarImg(avImg, row);
+ }, { once: true });
+ return;
+ }
+ }
+ avImg.src = defaultAvatarImg.src;
+ }
+
+ /** ลองโหลด avatar ซ้ำจน sprite/tint พร้อม (กันรูปหายตอนเปิด overlay) */
+ function applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt) {
+ attempt = attempt || 0;
+ setCyberHudScoreAvatarImg(avImg, row);
+ /* mock hud: ต้องได้ "หน้า" 120×120 (naturalWidth≥100) เท่านั้นถึงหยุด — กันค้างที่ raw frame 64×64 (เต็มตัว)
+ หรือ /img/ sprite เต็มตัว ก่อน composeCyberHudFacePortraitDataUrl จะได้หน้า (ตอน char-layer API ยังโหลด) */
+ const mockFaceOk = !isMissionMockHudPlay() || (avImg.naturalWidth >= 100 && avImg.naturalHeight >= 100);
+ const ok = cyberHudScoreAvatarImgLooksReady(avImg) && mockFaceOk
+ && (avImg.src.indexOf('data:image/') === 0 || (row.isMe && !isMissionMockHudPlay() && avImg.src.indexOf('/img/') >= 0));
+ if (ok) return;
+ if (attempt >= 16) return;
+ const cid = row.characterId ? String(row.characterId) : '';
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay(row));
+ applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt + 1);
+ }, delay);
+ }
+
+ function refreshGcmTfMockAvatarsPlay(rowEl, ranked) {
+ if (!rowEl || !Array.isArray(ranked)) return;
+ const imgs = rowEl.querySelectorAll('img.gcm-av');
+ ranked.forEach(function (r, idx) {
+ const img = imgs[idx];
+ if (!img) return;
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ });
+ }
+
+ function quizQuestionMissionJoystickResetVisual() {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (knob) knob.style.transform = 'translate(-50%, -50%)';
+ }
+
+ function quizQuestionMissionJoystickUpdateFromClientXY(clientX, clientY) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (!base || !knob) return;
+ const r = base.getBoundingClientRect();
+ const cx = r.left + r.width * 0.5;
+ const cy = r.top + r.height * 0.5;
+ let dx = clientX - cx;
+ let dy = clientY - cy;
+ const half = Math.min(r.width, r.height) * 0.5;
+ const maxKn = Math.max(18, half * 0.52);
+ const len = Math.hypot(dx, dy);
+ if (len > maxKn && len > 1e-6) {
+ dx = (dx / len) * maxKn;
+ dy = (dy / len) * maxKn;
+ }
+ const nlen = Math.hypot(dx, dy);
+ const dead = maxKn * 0.12;
+ if (nlen < dead) {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ knob.style.transform = 'translate(-50%, -50%)';
+ return;
+ }
+ quizQuestionMissionJoyVecX = dx / maxKn;
+ quizQuestionMissionJoyVecY = dy / maxKn;
+ knob.style.transform = 'translate(calc(-50% + ' + dx + 'px), calc(-50% + ' + dy + 'px))';
+ }
+
+ function syncQuizQuestionMissionJoystickPlay() {
+ const root = document.getElementById('quiz-question-mission-joystick');
+ if (!root) return;
+ const jumperOn = isJumpSurviveMissionHudActivePlay();
+ const on = !!((isQuizQuestionMissionHudActivePlay() || jumperOn) && !isChatFocused());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (on) {
+ /* สลับรูป joystick ตามฉาก: jumper → /img/Jumper/, quiz คำถาม → /img/QUESTION/ (เปลี่ยน src เฉพาะตอน skin เปลี่ยน) */
+ const skin = jumperOn ? 'jumper' : 'question';
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (bg && bg.dataset.joySkin !== skin) {
+ bg.src = jumperOn ? jumperAssetUrl('btn-joystick-1.png') : questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = skin;
+ }
+ if (knobImg && knobImg.dataset.joySkin !== skin) {
+ knobImg.src = jumperOn ? jumperAssetUrl('btn-joystick-2.png') : questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = skin;
+ }
+ }
+ if (!on) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ if (base && quizQuestionMissionJoyPointerId != null) {
+ try {
+ base.releasePointerCapture(quizQuestionMissionJoyPointerId);
+ } catch (_e) { /* ignore */ }
+ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ }
+
+ /* mng8a80o: scale HUD ทั้งก้อน (fix 1920×1080) ให้พอดี container แบบรักษา 16:9 — เทียบ scale.js ของ mockup Minigame-1
+ สำคัญ: คำนวณเฉพาะตอนเข้าโหมด/resize เท่านั้น (ห้ามอ่าน layout ทุกเฟรมใน draw loop → กระตุก) */
+ let _quizTfScaleLast = 0;
+ let _quizTfMockActivePrev = false;
+ function updateQuizTfMockScale(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root || !root.classList.contains('play-cyber-hud--quiz-tf-mock')) return;
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ root.style.position = 'fixed';
+ root.style.left = Math.round(r.left) + 'px';
+ root.style.top = Math.round(r.top) + 'px';
+ root.style.width = Math.round(cw) + 'px';
+ root.style.height = Math.round(ch) + 'px';
+ root.style.right = 'auto';
+ root.style.bottom = 'auto';
+ root.style.transform = 'none';
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ if (Math.abs(s - _quizTfScaleLast) < 0.0005
+ && root.dataset.qtfStackW === String(Math.round(cw))
+ && root.dataset.qtfStackH === String(Math.round(ch))) {
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ return;
+ }
+ _quizTfScaleLast = s;
+ root.dataset.qtfStackW = String(Math.round(cw));
+ root.dataset.qtfStackH = String(Math.round(ch));
+ root.style.setProperty('--qtf-scale', String(s));
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ }
+
+ function syncMg2MockJumpButtonBoundsPlay(stackRect, scale) {
+ const jumpBtn = document.getElementById('gauntlet-crown-jump-btn');
+ const hud = document.getElementById('play-cyber-hud');
+ const _jbSkin = hud ? hud.dataset.missionMockSkin : '';
+ if (!jumpBtn || !hud || (_jbSkin !== 'gauntlet_crown' && _jbSkin !== 'jumper')) {
+ if (jumpBtn) {
+ jumpBtn.style.removeProperty('left');
+ jumpBtn.style.removeProperty('top');
+ jumpBtn.style.removeProperty('width');
+ jumpBtn.style.removeProperty('height');
+ jumpBtn.style.removeProperty('right');
+ jumpBtn.style.removeProperty('bottom');
+ jumpBtn.style.removeProperty('transform');
+ jumpBtn.style.removeProperty('filter');
+ }
+ return;
+ }
+ if (jumpBtn.classList.contains('is-hidden')) return;
+ const r = stackRect || (document.getElementById('play-canvas-stack') || {}).getBoundingClientRect?.();
+ const s = Number.isFinite(scale) ? scale : _quizTfScaleLast;
+ if (!r || !(s > 0)) return;
+ jumpBtn.style.position = 'fixed';
+ jumpBtn.style.left = Math.round(r.left + 1648 * s) + 'px';
+ jumpBtn.style.top = Math.round(r.top + 818 * s) + 'px';
+ jumpBtn.style.width = Math.round(231 * s) + 'px';
+ jumpBtn.style.height = Math.round(231 * s) + 'px';
+ jumpBtn.style.right = 'auto';
+ jumpBtn.style.bottom = 'auto';
+ jumpBtn.style.transform = 'none';
+ jumpBtn.style.filter = 'drop-shadow(0 0 ' + Math.max(8, Math.round(18 * s)) + 'px rgba(255, 110, 220, 0.45))';
+ }
+
+ function clearQuizTfMockHudBoundsPlay(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root) return;
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'true');
+ root.style.removeProperty('left');
+ root.style.removeProperty('top');
+ root.style.removeProperty('width');
+ root.style.removeProperty('height');
+ root.style.removeProperty('right');
+ root.style.removeProperty('bottom');
+ root.style.removeProperty('transform');
+ delete root.dataset.qtfStackW;
+ delete root.dataset.qtfStackH;
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ }
+ /* mng8a80o: สรุปผลภารกิจ (06-summary) — scale popup 1451×851 ตามสูตรเดียวกับ HUD (min vw/1920, vh/1080) */
+ function missionMockOverlayScalePlay() {
+ const stack = document.getElementById('play-canvas-stack');
+ const r = stack && stack.getBoundingClientRect ? stack.getBoundingClientRect() : null;
+ const cw = Math.max(1, r && r.width ? r.width : window.innerWidth);
+ const ch = Math.max(1, r && r.height ? r.height : window.innerHeight);
+ return Math.min(cw / 1920, ch / 1080);
+ }
+ function updateGcmTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-mission-overlay');
+ if (!ov) return;
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gcm-tf-scale', String(s));
+ }
+ /* popup HOW TO PLAY (01-howto) — scale stage 1920×1080 · MG2 ยึด #play-canvas-stack เหมือน HUD */
+ function updateGchTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ const mg2 = ov.classList.contains('gch-mg2-mock');
+ if (mg2) {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ ov.style.position = 'fixed';
+ ov.style.left = Math.round(r.left) + 'px';
+ ov.style.top = Math.round(r.top) + 'px';
+ ov.style.width = Math.round(cw) + 'px';
+ ov.style.height = Math.round(ch) + 'px';
+ ov.style.right = 'auto';
+ ov.style.bottom = 'auto';
+ ov.style.transform = 'none';
+ ov.style.setProperty('--gch-tf-scale', String(s));
+ const stage = ov.querySelector('.gch-mg2-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ return;
+ }
+ clearGchMg2OverlayBoundsPlay(ov);
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gch-tf-scale', String(s));
+ }
+ let _quizTfScaleObserversBound = false;
+ function ensureQuizTfScaleResizeListener() {
+ if (_quizTfScaleObserversBound) return;
+ _quizTfScaleObserversBound = true;
+ const recompute = function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ if (ov && ov.classList.contains('gcm-quiz-tf-mock') && !ov.classList.contains('is-hidden')) updateGcmTfMockScale(ov);
+ const hv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hv && hv.classList.contains('gch-quiz-tf-mock') && !hv.classList.contains('is-hidden')) updateGchTfMockScale(hv);
+ };
+ window.addEventListener('resize', recompute);
+ window.addEventListener('orientationchange', recompute);
+ /* ResizeObserver จับ container ที่เปลี่ยนขนาดโดยไม่มี window resize (เช่น ลากแบ่งจอ editor) */
+ if (typeof ResizeObserver === 'function') {
+ try {
+ const ro = new ResizeObserver(recompute);
+ const host = document.querySelector('.game-wrap') || document.documentElement;
+ const stack = document.getElementById('play-canvas-stack');
+ if (host) ro.observe(host);
+ if (stack) ro.observe(stack);
+ } catch (e) { /* ignore */ }
+ }
+ }
+
+ function syncPlayCyberHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const on = useCyberPlayHud();
+ if (root) {
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) {
+ root.classList.remove('play-cyber-hud--question-mission');
+ root.classList.remove('play-cyber-hud--score-flush-left');
+ root.classList.remove('play-cyber-hud--jump-mission-hide-score');
+ root.classList.remove('play-cyber-hud--ss-self-integrity-left');
+ root.classList.remove('play-cyber-hud--last-light-no-time');
+ root.classList.remove('play-cyber-hud--quiz-tf-mock');
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ _quizTfMockActivePrev = false;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ }
+ if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on);
+ const gw = document.querySelector('.game-wrap');
+ if (gw) gw.classList.toggle('play-cyber-active', !!on);
+ if (!on || !root) return;
+ ensureMePlayTintFromLobbyStorage();
+ root.classList.toggle(
+ 'play-cyber-hud--question-mission',
+ !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay()),
+ );
+ /* Minigame 1–7 mission — HUD fix 1920×1080 ตาม mock (score-bar / profile qb-room) */
+ const missionMockOn = !!isMissionMockHudPlay();
+ root.classList.toggle('play-cyber-hud--quiz-tf-mock', missionMockOn);
+ document.documentElement.classList.toggle('play-mission-mock-hud', missionMockOn && on);
+ document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && getMissionMockHudSkinPlay() === 'gauntlet_crown');
+ applyMissionMockHudCssVarsPlay(root);
+ const tfMockOn = root.classList.contains('play-cyber-hud--quiz-tf-mock');
+ if (tfMockOn) {
+ ensureQuizTfScaleResizeListener();
+ updateQuizTfMockScale(root);
+ if (!_quizTfMockActivePrev) {
+ requestAnimationFrame(function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ });
+ }
+ } else if (_quizTfMockActivePrev) {
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ _quizTfMockActivePrev = tfMockOn;
+ root.classList.toggle(
+ 'play-cyber-hud--stack-tower-canvas-hud',
+ !!(playBotsEnabled() && stackMini && isStack() && isStackTowerMissionUiMapPlay()),
+ );
+ root.classList.toggle('play-cyber-hud--ss-self-integrity-left', !!isSpaceShooterMissionHudActivePlay());
+ /** แถบ SCORE แนวนอน (mno9kb07) — ใช้รูปแบบเดียวกับ quiz_carry / พรีวิว editor */
+ root.classList.toggle(
+ 'play-cyber-hud--gauntlet-crown-strip',
+ !!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry())
+ && !(missionMockOn && getMissionMockHudSkinPlay() === 'gauntlet_crown')),
+ );
+ root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry()));
+ root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay());
+ /** Last Light mno9kb07: ไม่แสดง TIME / ซูมใต้เวลา — จบด้วยรันเวย์ ไม่ใช้นาฬิกา */
+ root.classList.toggle('play-cyber-hud--last-light-no-time', !!(on && isGauntletCrownHeistMapPlay()));
+ const timeLabelEl = root.querySelector('.play-cyber-time-label');
+ if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : '';
+ const timeVal = document.getElementById('play-cyber-time-val');
+ const timeSub = document.getElementById('play-cyber-time-sub');
+ const portrait = document.getElementById('play-cyber-portrait-img');
+ const statusEl = document.getElementById('play-cyber-self-status');
+ const hintEl = document.getElementById('play-cyber-hint');
+ const previewEl = document.getElementById('play-cyber-preview-line');
+
+ if (timeSub) {
+ const bbMegaLiveHud = isBalloonBoss() && isMegaVirusMissionShellMapPlay() && !isGauntletCrownPregameBlockingPlay();
+ timeSub.textContent = isQuizQuestionMissionHudActivePlay()
+ ? (playQuizPhaseLocal === 'read' ? 'READ · อ่านคำถาม'
+ : (playQuizPhaseLocal === 'answer' ? 'ANSWER · เดินไปโซน จริง / เท็จ'
+ : 'QUIZ MISSION · ประลองความรู้'))
+ : (isSpaceShooterMissionHudActivePlay()
+ ? 'SPACE SHOOTER · ARCADE — TIME (sec) · ภารกิจยิงยาน'
+ : (isStackTowerMissionHudActivePlay()
+ ? 'TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส'
+ : (isQuizCarry()
+ ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)'
+ : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
+ : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
+ : (isBalloonBoss() ? (bbMegaLiveHud ? '' : 'BALLOON BOSS · MEGA VIRUS')
+ : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))))));
+ timeSub.style.display = bbMegaLiveHud ? 'none' : '';
+ }
+ const zoomHintEl = document.getElementById('play-cyber-embed-zoom-hint');
+ if (zoomHintEl) {
+ const showEmbedZoom = !!(previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()
+ && !(isBalloonBoss() && isMegaVirusMissionShellMapPlay()));
+ if (showEmbedZoom) {
+ const zNum = Number(playEmbedUserZoomMul.toFixed(2));
+ zoomHintEl.textContent = '×' + String(zNum);
+ zoomHintEl.classList.remove('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'false');
+ } else {
+ zoomHintEl.textContent = '';
+ zoomHintEl.classList.add('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'true');
+ }
+ }
+ if (timeVal) {
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'live' || quizQuestionMissionPhase === 'ended') {
+ timeVal.textContent = '···';
+ } else if (!playQuizPhaseEndsAt) {
+ timeVal.textContent = '—';
+ } else {
+ timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)));
+ }
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase === 'live') {
+ const remSt = Math.max(0, Math.ceil(stackTowerMissionTimeLimitSecPlay() - stackTowerMissionElapsedSecPlay()));
+ timeVal.textContent = String(remSt);
+ } else if (stackTowerMissionPhase === 'howto' || stackTowerMissionPhase === 'countdown') {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isQuizCarry()) {
+ if (quizCarrySessionEnded) {
+ timeVal.textContent = '0';
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedCountdownEndAt > Date.now()) {
+ const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem);
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) {
+ const rem2 = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem2);
+ } else if (quizCarryPregameActive) {
+ timeVal.textContent = '···';
+ } else if (!quizCarryCurrent) {
+ timeVal.textContent = '—';
+ } else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000)));
+ } else if (quizCarryAnswerCloseAt > Date.now()) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000)));
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isGauntlet()) {
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '∞';
+ }
+ } else if (isSpaceShooter()) {
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) {
+ const limSs = spaceShooterTimeLimitSecPlay();
+ if (spaceShooterMissionPhase === 'howto' || spaceShooterMissionPhase === 'countdown') {
+ timeVal.textContent = String(limSs > 0 ? limSs : 0);
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else {
+ const remSs = spaceShooterRemainingSecPlay();
+ timeVal.textContent = remSs != null ? String(remSs) : '∞';
+ }
+ } else if (spaceShooterGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = spaceShooterRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isBalloonBoss()) {
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (balloonBossGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = balloonBossRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) {
+ timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0');
+ } else {
+ const rem = jumpSurviveRemainingSecMission();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else {
+ const t = jumpSurviveSessionStartMs > 0
+ ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
+ : 0;
+ timeVal.textContent = String(t);
+ }
+ }
+
+ if (portrait) {
+ applyCyberHudProfilePortraitImgWithRetry(portrait, 0);
+ }
+ if (statusEl) {
+ if (isSpaceShooterMissionHudActivePlay()) {
+ /* MG6 life ย้ายไป overlay เฉพาะ #mg6-life-hud (syncSpaceShooterLifeDomHudPlay) — คุมตำแหน่ง/ขนาดเท่า MG3 */
+ statusEl.textContent = '';
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ statusEl.textContent = 'QUIZ LINK · ประลองความรู้';
+ } else if (isStackTowerMissionHudActivePlay()) {
+ statusEl.textContent = 'DECRYPT UPLINK · TOWER';
+ } else if (isJumpSurviveMissionHudActivePlay()) {
+ statusEl.textContent = 'JUMPER LINK · SURVIVE';
+ } else if (isQuizCarry() && quizCarrySessionEnded) {
+ statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม';
+ } else if (isJumpSurvive() && jumpSurviveEliminated) {
+ statusEl.textContent = 'SPECTATOR · LINK SEVERED';
+ } else if (isBalloonBoss() && me.balloonBossEliminated) {
+ statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
+ } else if (isGauntletCrownHeistMapPlay()) {
+ statusEl.textContent = '';
+ } else {
+ statusEl.textContent = 'OPERATOR ONLINE';
+ }
+ }
+ if (hintEl) {
+ if (isQuizQuestionMissionHudActivePlay()) {
+ hintEl.textContent = '';
+ hintEl.setAttribute('aria-hidden', 'true');
+ } else {
+ hintEl.removeAttribute('aria-hidden');
+ if (isStackTowerMissionHudActivePlay()) {
+ hintEl.textContent = 'กด SPACE / ENTER หรือคลิกที่จอ = DROP · วางต่อเนื่องแม่น = โบนัส · ถอดรหัสให้ครบ 100% ภายในเวลา';
+ } else if (isQuizCarry()) {
+ hintEl.textContent = quizCarrySessionEnded
+ ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
+ : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
+ } else if (isJumpSurvive()) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)'
+ : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน';
+ } else {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'Spectate remaining nodes · Exit room to reset session'
+ : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
+ }
+ } else if (isSpaceShooter()) {
+ hintEl.textContent = spaceShooterGameEnded
+ ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
+ : 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
+ } else if (isBalloonBoss()) {
+ hintEl.textContent = balloonBossGameEnded
+ ? 'จบแล้ว · Session ended — see overlay'
+ : (me.balloonBossEliminated
+ ? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
+ : (isMegaVirusMissionShellMapPlay()
+ ? 'ลูกศรบนวงหมุนเอง — ยิงตามมุมลูกศร ณ ตอนกด Space · Ring spins · Space = fire along arrow'
+ : 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ'));
+ } else if (isGauntletCrownHeistMapPlay()) {
+ hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end';
+ } else {
+ hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
+ }
+ }
+ }
+ if (previewEl) {
+ if (playBotsEnabled() && mapData) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ const botTarget = playBotTargetHeadcount();
+ const simLabel = detectiveCaseFillBots ? 'CASE' : 'SIM';
+ previewEl.textContent = `${simLabel} · humans ${human} + bots ${bots} · target ${botTarget}`;
+ previewEl.classList.remove('is-hidden');
+ } else {
+ previewEl.textContent = '';
+ previewEl.classList.add('is-hidden');
+ }
+ }
+
+ syncQuizQuestionMissionCyberCenterHud();
+
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return;
+ /* mnptfts2 — ไม่แสดงแผง SCORE (เหลือ TIME + โปรไฟล์) */
+ if (isJumpSurviveMissionUiMapPlay()) {
+ ul.innerHTML = '';
+ ul.dataset.cyberSig = '';
+ const boardSkip = ul.closest('.play-cyber-scoreboard');
+ if (boardSkip) boardSkip.classList.remove('play-cyber-scoreboard--crown-strip');
+ ul.classList.remove('play-cyber-score-list--crown-strip');
+ return;
+ }
+ const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry();
+ const mg2MockHud = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ const board = ul.closest('.play-cyber-scoreboard');
+ if (board) {
+ board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud && !mg2MockHud);
+ board.classList.toggle('play-cyber-scoreboard--mg2-mock', !!mg2MockHud);
+ board.classList.toggle('mg2-score-bar', !!mg2MockHud);
+ }
+ ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud && !mg2MockHud);
+ ul.classList.toggle('play-cyber-score-list--mg2-mock', !!mg2MockHud);
+ const panelTitle = root.querySelector('.play-cyber-panel-title');
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (panelTitle && crownHead && crownImg) {
+ if (mg2MockHud) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ panelTitle.classList.add('is-hidden');
+ } else if (stripScoreHud) {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE :';
+ panelTitle.classList.remove('is-hidden');
+ } else {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE';
+ panelTitle.classList.remove('is-hidden');
+ }
+ }
+ const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay();
+ const quizMissionHud = isQuizQuestionMissionHudActivePlay();
+ const stackMissionHud = isStackTowerMissionHudActivePlay();
+ const jumpMissionLiveHud = isJumpSurviveMissionHudActivePlay();
+ const spaceMissionLiveHud = isSpaceShooterMissionHudActivePlay();
+ /** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp */
+ const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud;
+ /** mission mock HUD — layout แถว: [avatar | ชื่อบน/คะแนนล่าง] (meta column) */
+ const tfMockHud = isMissionMockHudPlay();
+ const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ const rows = [];
+ rows.push({
+ id: myId,
+ nickname: me.nickname || nick,
+ characterId: me.characterId || getPlayCharacterId(),
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : (nick || 'local'))),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(myId)
+ : (stackMissionHud
+ ? (playBotsEnabled() ? (me.stackPreviewHumanPts || 0) : stackTeamPts)
+ : (isGauntlet() ? (me.gauntletScore || 0)
+ : (isSpaceShooter() ? (me.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (me.balloonBossScore || 0)
+ : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(me.y),
+ eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated),
+ isMe: true,
+ });
+ others.forEach((o, id) => {
+ rows.push({
+ id,
+ nickname: o.nickname || id.slice(0, 8),
+ characterId: o.characterId,
+ playTint: o.playTint || resolvePlayTintForPeer(id),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(id)
+ : (stackMissionHud
+ ? (playBotsEnabled() && isPreviewBotId(id) ? (o.stackBotScore || 0) : stackTeamPts)
+ : (isGauntlet() ? (o.gauntletScore || 0)
+ : (isSpaceShooter() ? (o.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (o.balloonBossScore || 0)
+ : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(o.y),
+ eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
+ || !!(isBalloonBoss() && o.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated),
+ isMe: false,
+ });
+ });
+ let leaderId = null;
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ let mx = -1;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].score > mx) {
+ mx = rows[i].score;
+ leaderId = rows[i].id;
+ }
+ }
+ } else {
+ let best = Infinity;
+ for (let i = 0; i < rows.length; i++) {
+ if (!rows[i].eliminated && rows[i].y < best) {
+ best = rows[i].y;
+ leaderId = rows[i].id;
+ }
+ }
+ }
+ rows.sort((a, b) => {
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ if (b.score !== a.score) return b.score - a.score;
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th');
+ }
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return a.y - b.y;
+ });
+
+ /* Minigame-2 รองรับสูงสุด 6 ผู้เล่น (GAUNTLET_MAX_PLAYERS) — แถบคะแนนต้องโชว์ครบ 6 รวมตัวเรา
+ (เดิม cap 5 ตาม mockup ที่วาดตัวอย่าง 5 คน ทำให้ตัวเราคะแนนต่ำสุดถูกตัดออก) */
+ const cap = mg2MockHud ? 6 : (stripScoreHud ? (isQuizCarry() ? 8 : 6) : 8);
+ let hudRows = rows.slice(0, Math.min(cap, rows.length));
+ if (stripScoreHud) {
+ const mi = hudRows.findIndex((row) => row.isMe);
+ if (mi >= 0) {
+ const mine = hudRows.splice(mi, 1)[0];
+ hudRows.push(mine);
+ }
+ }
+ /* กันกระตุก: rebuild DOM เฉพาะตอนเนื้อหา (id/score/order/สี/ตกรอบ) เปลี่ยน — ไม่ลบ-สร้างทุกเฟรม */
+ const listSig = [
+ cyberQmMockHud ? 'qm' : '', mg2MockHud ? 'mg2' : '', stripScoreHud ? 'strip' : '', leaderId == null ? '' : String(leaderId),
+ hudRows.map(function (rw) {
+ const t = rw.playTint || {};
+ return String(rw.id) + ':' + String(rw.score) + ':' + (rw.eliminated ? 1 : 0)
+ + ':' + String(rw.characterId || '') + ':' + String(t.body || '') + ':' + String(t.head || '');
+ }).join(','),
+ ].join('|');
+ if (ul.dataset.cyberSig === listSig) {
+ const existImgs = ul.querySelectorAll('img.play-cyber-score-av');
+ if (existImgs.length === hudRows.length) {
+ for (let i = 0; i < existImgs.length; i++) {
+ if (!cyberHudScoreAvatarImgLooksReady(existImgs[i])) setCyberHudScoreAvatarImg(existImgs[i], hudRows[i]);
+ }
+ return;
+ }
+ }
+ ul.dataset.cyberSig = listSig;
+ ul.innerHTML = '';
+ for (let r = 0; r < hudRows.length; r++) {
+ const row = hudRows[r];
+ const li = document.createElement('li');
+ li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '')
+ + (cyberQmMockHud ? ' play-cyber-score-row--qm-mock' : '')
+ + (mg2MockHud ? ' play-cyber-score-row--mg2-mock' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-row--crown-strip' : '');
+ if (row.id != null) li.setAttribute('data-peer-id', String(row.id));
+ const av = document.createElement('img');
+ av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '')
+ + (mg2MockHud ? ' play-cyber-score-av--mg2' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-av--crown-strip' : '');
+ av.alt = '';
+ setCyberHudScoreAvatarImg(av, row);
+ const sc = document.createElement('span');
+ sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '')
+ + (mg2MockHud ? ' play-cyber-mg2-score' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-crown-strip-val' : '');
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ sc.textContent = String(row.score);
+ } else {
+ sc.textContent = row.eliminated ? '—' : String(r + 1);
+ }
+ if (cyberQmMockHud) {
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ sc.insertBefore(crown, sc.firstChild);
+ }
+ const qmLeft = document.createElement('div');
+ qmLeft.className = 'play-cyber-qm-left';
+ const avWrap = document.createElement('span');
+ avWrap.className = 'play-cyber-qm-av-wrap';
+ if (tfMockHud) {
+ /* mng8a80o: หน้าตัวละครต้องอยู่ "บน" กรอบ (กรอบกลางทึบ) + clip ให้อยู่ในขอบ */
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-qm-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ } else {
+ avWrap.appendChild(av);
+ }
+ const qmName = document.createElement('span');
+ qmName.className = 'play-cyber-qm-name';
+ qmName.textContent = String(row.nickname || '').trim().slice(0, 28);
+ qmLeft.appendChild(avWrap);
+ if (tfMockHud) {
+ /* mockup Minigame-1: avatar ซ้าย | (ชื่อบน / คะแนนล่าง) ในคอลัมน์ meta */
+ const qmMeta = document.createElement('div');
+ qmMeta.className = 'play-cyber-qm-meta';
+ qmMeta.appendChild(qmName);
+ qmMeta.appendChild(sc);
+ qmLeft.appendChild(qmMeta);
+ li.appendChild(qmLeft);
+ } else {
+ qmLeft.appendChild(qmName);
+ li.appendChild(qmLeft);
+ li.appendChild(sc);
+ }
+ } else if (mg2MockHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-mg2-player';
+ const name = document.createElement('div');
+ name.className = 'play-cyber-mg2-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 12)));
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-mg2-av-wrap';
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-mg2-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ cell.appendChild(name);
+ cell.appendChild(avWrap);
+ cell.appendChild(sc);
+ li.appendChild(cell);
+ } else if (stripScoreHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-crown-strip-cell';
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-crown-strip-av-wrap';
+ avWrap.appendChild(av);
+ const meta = document.createElement('div');
+ meta.className = 'play-cyber-crown-strip-meta';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-crown-strip-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 16)));
+ meta.appendChild(name);
+ meta.appendChild(sc);
+ cell.appendChild(avWrap);
+ cell.appendChild(meta);
+ li.appendChild(cell);
+ } else {
+ const mid = document.createElement('div');
+ mid.className = 'play-cyber-score-mid';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-score-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
+ const st = document.createElement('span');
+ st.className = 'play-cyber-score-state';
+ st.textContent = (isQuizCarry() || quizMissionHud)
+ ? ''
+ : (row.eliminated
+ ? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE'))
+ : (isBalloonBoss()
+ ? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
+ const ox = others.get(row.id);
+ return ox ? (ox.balloonBossBalloons | 0) : 0;
+ })())))
+ : 'LINK_OK'));
+ mid.appendChild(name);
+ mid.appendChild(st);
+ li.appendChild(av);
+ li.appendChild(mid);
+ li.appendChild(sc);
+ }
+ ul.appendChild(li);
+ }
+ }
+
+ function draw() {
+ try {
+ if (!mapData) {
+ document.documentElement.classList.remove('play-stack-tower-pixel-canvas');
+ return;
+ }
+ document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay());
+ } catch (e) { /* ignore */ }
+ if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ const w = mapData.width, h = mapData.height;
+
+ let zDraw = computePlayCameraZDrawPlay();
+ const stackCam = isStack() ? getStackCameraCentersPx() : null;
+ let camX = stackCam ? stackCam.px : me.x * tileSize;
+ let camY = stackCam ? stackCam.py : me.y * tileSize;
+ if (isJumpSurvive()) {
+ camX = jumpSurviveCamCenterX;
+ camY = jumpSurviveCamCenterY;
+ }
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize, mhPx = h * tileSize;
+ camX = mwPx * 0.5;
+ camY = mhPx * 0.5;
+ } else if (isQuizCarry()) {
+ const qc = getQuizCarryMapCameraWorldCenterPxPlay();
+ if (qc) {
+ camX = qc.cx;
+ camY = qc.cy;
+ }
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ camX = qmc.cx;
+ camY = qmc.cy;
+ }
+ } else if (isQuizBattle()) {
+ /* บอร์ดควิซแนวตั้ง (20×40) — ล็อกกล้องกลางแนวนอน (ไม่ไถลซ้าย/ขวาตามผู้เล่น) เลื่อนตามแนวตั้งเท่านั้น ตรง mock */
+ camX = (w * tileSize) / 2;
+ }
+ const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw);
+ if (gauntletGroupCam) {
+ camX = gauntletGroupCam.px;
+ camY = gauntletGroupCam.py;
+ }
+ /* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */
+ lastPlayZDrawForInput = zDraw;
+ const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
+ const halfW = canvas.width / (2 * zDraw);
+ const halfH = canvas.height / (2 * zDraw);
+
+ // world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
+ const worldMinX = camX - halfW;
+ const worldMaxX = camX + halfW;
+ const worldMinY = camY - halfH;
+ const worldMaxY = camY + halfH;
+
+ const mapWpx = w * tileSize, mapHpx = h * tileSize;
+ const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
+ const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
+ const timeMs = Date.now();
+
+ if (playScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawPlayScrollBgFullCanvas(canvas.width, canvas.height);
+ } else if (gauntletCrownRunwayBgDrawActivePlay()) {
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, {
+ worldMinX,
+ worldMaxX,
+ camX,
+ camY,
+ zDraw: zDraw,
+ mapWpx,
+ mapHpx,
+ });
+ } else if (stackTowerScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerScrollBgFullCanvas(canvas.width, canvas.height, zDraw);
+ } else if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
+ /* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, canvas.width, canvas.height);
+ const natW = mapBackgroundImg.naturalWidth;
+ const natH = mapBackgroundImg.naturalHeight;
+ const bgMinX = Math.max(0, worldMinX);
+ const bgMaxX = Math.min(mapWpx, worldMaxX);
+ const bgMinY = Math.max(0, worldMinY);
+ const bgMaxY = Math.min(mapHpx, worldMaxY);
+ if (bgMaxX > bgMinX && bgMaxY > bgMinY) {
+ const srcX = (bgMinX / mapWpx) * natW;
+ const srcY = (bgMinY / mapHpx) * natH;
+ const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
+ const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
+ const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
+ const destY = (bgMinY - camY) * zDraw + canvas.height / 2
+ - (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0);
+ const destW = (bgMaxX - bgMinX) * zDraw;
+ const destH = (bgMaxY - bgMinY) * zDraw;
+ if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
+ ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
+ }
+ }
+ } else {
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ if (isSpaceShooter()) {
+ ctx.save();
+ for (let s = 0; s < 120; s++) {
+ const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
+ const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
+ const br = (s % 4 === 0) ? 1.6 : 0.9;
+ ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, br, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ } else if (isBalloonBoss()) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
+ for (let col = 0; col < 24; col++) {
+ const x = (col + 0.5) * (canvas.width / 24);
+ for (let r = 0; r < 18; r++) {
+ const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
+ ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
+ ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
+ }
+ }
+ ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
+ ctx.beginPath();
+ ctx.moveTo(0, canvas.height);
+ const baseY = canvas.height * 0.88;
+ for (let k = 0; k <= 40; k++) {
+ const bx = (k / 40) * canvas.width;
+ const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
+ ctx.lineTo(bx, by);
+ }
+ ctx.lineTo(canvas.width, canvas.height);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ function worldToScreen(wx, wy) {
+ const sx = (wx - camX) * zDraw + canvas.width / 2;
+ const sy = (wy - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ return [sx, sy];
+ }
+
+ function drawGridImagePlacementsLayer() {
+ if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
+ for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
+ const sp = mapData.gridImagePlacements[gi];
+ const wx0 = sp.x * tileSize;
+ const wy0 = sp.y * tileSize;
+ const ww = sp.w * tileSize;
+ const wh = sp.h * tileSize;
+ if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
+ let gim = mapGridImageImgs[sp.i];
+ let carrySpriteAlpha = 1;
+ if (isQuizCarry()) {
+ const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp);
+ const meHolding = me && me.quizCarryHeld != null;
+ let carryVisualActive = false;
+ if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true;
+ else if (domOpt == null && onHub && meHolding) carryVisualActive = true;
+ if (carryVisualActive) {
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) gim = gh;
+ else carrySpriteAlpha = 0.32;
+ }
+ }
+ if (!gim || !gim.complete || !gim.naturalWidth) continue;
+ const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
+ const sy0 = (wy0 - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ const sw = ww * zDraw;
+ const sh = wh * zDraw;
+ ctx.save();
+ if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha;
+ ctx.beginPath();
+ ctx.rect(sx0, sy0, sw, sh);
+ ctx.clip();
+ ctx.drawImage(gim, sx0, sy0, sw, sh);
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
+ }
+ }
+
+ const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
+ const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
+ const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = isFrogger() ? getLane(y) : null;
+ let rowFill = null;
+ if (lane) {
+ if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
+ else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
+ else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
+ else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
+ } else if (isGauntlet()) {
+ rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
+ } else if (isStack()) {
+ rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
+ } else if (isJumpSurvive()) {
+ rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
+ } else if (isSpaceShooter()) {
+ rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
+ } else if (isBalloonBoss()) {
+ rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
+ } else if (isQuizBattle()) {
+ rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
+ }
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const ob = mapData.objects?.[y]?.[x] ?? 0;
+ if (ob === 1) {
+ ctx.fillStyle = 'rgba(65,72,104,0.92)';
+ ctx.fillRect(sx, sy, size, size);
+ ctx.strokeStyle = '#565f89';
+ ctx.strokeRect(sx, sy, size, size);
+ } else {
+ const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
+ if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
+ else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
+ else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
+ ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
+ ctx.fillRect(sx, sy, size, size);
+ }
+ }
+ }
+ }
+ }
+ drawGridImagePlacementsLayer();
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
+ if (isInter) {
+ ctx.fillStyle = 'rgba(158,206,106,0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(158,206,106,0.8)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (isQuiz()) {
+ const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isQuizQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
+ if (isQuizT) {
+ ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
+ if (isQuizF) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ }
+ if (isQuizCarry()) {
+ const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
+ if (hub) {
+ ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isCarryQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
+ if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
+ ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
+ /* ไฮไลต์เส้นทางบาง ๆ (โชว์เฉพาะตอนเปิดกริด) ให้เห็นถนนจาก design ทะลุ */
+ ctx.fillStyle = 'rgba(150, 195, 255, 0.12)';
+ ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
+ }
+ if (isQuizBattle() && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) {
+ const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x];
+ const comp = mapData.quizBattleDomeComp;
+ /* วาดหีบครั้งเดียวต่อโดม (ช่องบน-ซ้ายสุดของกลุ่ม comp) — กันวาดซ้อน */
+ const upSame = cid > 0 && comp && comp[y - 1] && comp[y - 1][x] === cid;
+ const leftSame = cid > 0 && comp && comp[y] && comp[y][x - 1] === cid;
+ if (!upSame && !leftSame) {
+ const passed = cid > 0 && quizBattlePassedComps.has(cid);
+ const wrong = passed && !quizBattleAnsweredComps.has(cid);
+ drawQuizBattleChest(ctx, sx + size / 2, sy + size / 2, size, passed, cid || 0, wrong);
+ }
+ }
+ if (isStack()) {
+ const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea;
+ if (sr && sr[y] && sr[y][x] === 1) {
+ ctx.fillStyle = 'rgba(125, 207, 255, 0.28)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (sl && sl[y] && sl[y][x] === 1) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.26)';
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)';
+ ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ /* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */
+ if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
+ const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
+ if (sv >= 1 && sv <= 6) {
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
+ ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
+ ctx.textAlign = 'left';
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && mapData.jumpSurvivePlatformArea) {
+ const pa = mapData.jumpSurvivePlatformArea;
+ const sc = jumpSurvivePlatformScrollPx;
+ const tsz = tileSize;
+ const period = h * tsz;
+ const visTop = worldMinY;
+ const visBot = worldMaxY;
+ const eps = 1e-6;
+ for (let py = 0; py < h; py++) {
+ for (let px = 0; px < w; px++) {
+ if (!pa[py] || pa[py][px] !== 1) continue;
+ const vIdx = jumpSurvivePlatformVariantIndexAtPlay(mapData, px, py);
+ if (px > 0 && pa[py][px - 1] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px - 1, py) === vIdx) {
+ continue;
+ }
+ let runLen = 1;
+ while (px + runLen < w && pa[py][px + runLen] === 1
+ && jumpSurvivePlatformVariantIndexAtPlay(mapData, px + runLen, py) === vIdx) runLen += 1;
+ const cfg = playJumpSurvivePlatformTiles[vIdx - 1] || { url: '', w: 0, h: 0 };
+ const jumpPlatU = normalizeGauntletAssetUrlForPlay(cfg.url || '');
+ const jumpPlatRec = jumpPlatU ? ensureGauntletAssetImage(jumpPlatU) : null;
+ const jumpPlatImg = jumpPlatRec && jumpPlatRec.ready && jumpPlatRec.img && jumpPlatRec.img.naturalWidth > 0 ? jumpPlatRec.img : null;
+ const tileWorldLeft = px * tsz;
+ const tileWorldRight = (px + runLen) * tsz;
+ if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
+ const rawTop = py * tsz + sc;
+ const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
+ const kMax = Math.floor((visBot - rawTop - eps) / period);
+ if (kMax < kMin) continue;
+ for (let kk = kMin; kk <= kMax; kk++) {
+ const platTop = rawTop + kk * period;
+ if (platTop + tsz <= visTop || platTop >= visBot) continue;
+ const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
+ const psz = tsz * zDraw;
+ const segScreenW = runLen * psz;
+ let dw;
+ let dh = cfg.h > 0 ? cfg.h * zDraw : psz - 4;
+ if (runLen > 1) {
+ dw = segScreenW;
+ } else {
+ dw = cfg.w > 0 ? cfg.w * zDraw : psz - 4;
+ const maxDim = psz * 4;
+ if (dw > maxDim) dw = maxDim;
+ }
+ const maxDh = psz * 4;
+ if (dh > maxDh) dh = maxDh;
+ const marginBot = 2;
+ const dx = runLen > 1 ? psx : psx + (psz - dw) / 2;
+ const dy = psy + psz - dh - marginBot;
+ if (jumpPlatImg) {
+ ctx.save();
+ try { ctx.imageSmoothingEnabled = true; } catch (e) { /* ignore */ }
+ ctx.drawImage(jumpPlatImg, dx, dy, dw, dh);
+ ctx.restore();
+ } else {
+ const fillW = runLen > 1 ? segScreenW - 6 : psz - 6;
+ ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
+ ctx.fillRect(psx + 3, psy + 3, fillW, psz - 6);
+ const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
+ grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
+ grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
+ grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(psx + 4, psy + 4, fillW - 2, psz - 8);
+ ctx.lineWidth = 1;
+ if (zDraw >= 1.15 && psz > 22) {
+ ctx.save();
+ ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const cx = psx + (runLen > 1 ? segScreenW * 0.5 : psz * 0.5);
+ ctx.fillText('DATA', cx, psy + psz * 0.38);
+ ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
+ ctx.fillText('/SEC/BLOCK', cx, psy + psz * 0.62);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && showGrid && mapData.jumpSurviveHazardArea) {
+ const ha = mapData.jumpSurviveHazardArea;
+ const tsz = tileSize;
+ for (let py = startTileY; py <= endTileY; py++) {
+ for (let px = startTileX; px <= endTileX; px++) {
+ if (!ha[py] || ha[py][px] !== 1) continue;
+ const [psx, psy] = worldToScreen(px * tsz, py * tsz);
+ const psz = tsz * zDraw;
+ ctx.fillStyle = 'rgba(200, 28, 55, 0.38)';
+ ctx.fillRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.strokeStyle = 'rgba(255, 90, 110, 0.82)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.lineWidth = 1;
+ }
+ }
+ }
+ if (showGrid) {
+ if (isFrogger() && mapData.lanes) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = getLane(y);
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
+ const positions = getVehiclePositions(lane, mapData.width, timeMs);
+ const isRoad = lane.type === 'road';
+ for (let i = 0; i < positions.length; i++) {
+ const vx = positions[i];
+ const wx = vx * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw * (isRoad ? 1.2 : 1.5);
+ ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
+ ctx.fillRect(sx, sy, size, size * 0.7);
+ ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
+ ctx.strokeRect(sx, sy, size, size * 0.7);
+ }
+ }
+ }
+ }
+
+ if (isQuizCarry()) {
+ drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
+ }
+
+ if (isStack() && stackMini) {
+ drawStackMinigame(ctx, worldToScreen, zDraw);
+ }
+
+ const gauntletObsAlpha = isGauntlet() ? gauntletCrownRunwayObstacleDrawAlphaPlay() : 1;
+ const gauntletObsDraw = (isGauntlet() && gauntletObsAlpha > 0.001)
+ ? getGauntletObsDrawPositionsAt(performance.now())
+ : [];
+ if (isGauntlet() && gauntletObsDraw.length) {
+ ctx.save();
+ ctx.globalAlpha = gauntletObsAlpha;
+ const stx = Math.max(0, Math.floor(worldMinX / tileSize));
+ const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const sty = Math.max(0, Math.floor(worldMinY / tileSize));
+ const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ for (let i = 0; i < gauntletObsDraw.length; i++) {
+ const o = gauntletObsDraw[i];
+ if (!o) continue;
+ if (o.kind === 'lane' && typeof o.y === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue;
+ const size = tileSize * zDraw;
+ const inner = Math.max(2, size - 4);
+ const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize);
+ const dx = sxC - inner / 2;
+ const dy = syCellBottom - inner;
+ const laneRec = pickGauntletLaneImageRec(o.id);
+ if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) {
+ try {
+ ctx.drawImage(laneRec.img, dx, dy, inner, inner);
+ } catch (e) {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else if (o.kind === 'laser' && typeof o.drawX === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2) continue;
+ /* ใช้โซนที่ editor กำหนด (gauntletLaserRowStart..End เช่น 4..18) — หัว/ท้าย emitter อยู่ตามนั้น */
+ const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h);
+ const wx0 = o.drawX * tileSize;
+ const wx1 = (o.drawX + 1) * tileSize;
+ const wy0 = laserRow0 * tileSize;
+ const wy1 = (laserRow1 + 1) * tileSize;
+ const [sx0, syTop] = worldToScreen(wx0, wy0);
+ const [sx1, syBot] = worldToScreen(wx1, wy1);
+ const rx = Math.min(sx0, sx1);
+ const rw = Math.max(2, Math.abs(sx1 - sx0));
+ const ry = Math.min(syTop, syBot);
+ const rh = Math.abs(syBot - syTop);
+ drawGauntletLaserColumnScreen(rx, ry, rw, rh);
+ }
+ }
+ ctx.restore();
+ }
+
+ /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */
+ function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) {
+ const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl);
+ const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null;
+ const raw = String(signText || '').trim();
+ if (!raw && !imgUrlSan && !gridHeldDraw) return;
+ ctx.save();
+ const heldPs = quizCarryPlaqueMapScaleClampedPlay();
+ const bodyH = Math.max(18, feetSy - spriteTopY);
+ const midBodyY = spriteTopY + bodyH * 0.48;
+ const bodyCx = sxFeet;
+ const halfW = Math.max(10, halfSpriteW || 0);
+ const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5));
+ const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs));
+
+ const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs));
+ ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`;
+ const padX = 12;
+ const padY = 9;
+ const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2));
+ let lines;
+ if (!raw) {
+ lines = [];
+ } else {
+ lines = canvasWordWrapLines(ctx, raw, maxInner);
+ if (!lines.length) lines = [raw];
+ if (lines.length > 3) lines = lines.slice(0, 3);
+ for (let i = 0; i < lines.length; i++) {
+ let s = lines[i];
+ if (ctx.measureText(s).width <= maxInner) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1);
+ lines[i] = s + '…';
+ }
+ }
+ let maxLineW = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lw = ctx.measureText(lines[i]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const lineH = fontPx * 1.22;
+ let w;
+ let h;
+ if (!lines.length && (imgUrlSan || gridHeldDraw)) {
+ w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2)));
+ h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2));
+ } else {
+ w = Math.ceil(Math.max(maxLineW, 56) + padX * 2);
+ h = Math.ceil(lines.length * lineH + padY * 2);
+ w = Math.min(maxPlaqueW, Math.max(96, w));
+ h = Math.max(40, h);
+ }
+
+ let signX = bodyCx - w / 2;
+ let signY = midBodyY - h / 2;
+
+ if (signX + w > canvas.width - 6) signX = canvas.width - w - 6;
+ if (signX < 6) signX = 6;
+ if (signY < 6) signY = 6;
+ if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
+
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw });
+ ctx.restore();
+ }
+
+ function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload, crownPenaltyFxUntil) {
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const air = Number(gauntletAirTicks) || 0;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ /* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const boxW = tileSize * zDraw * Math.max(cw, 1) * 1.15;
+ const boxH = tileSize * zDraw * Math.max(ch, 1) * 1.35;
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const dir = direction || 'down';
+ const charImg = resolvePlayDrawCharSource(characterId, dir, timeMs, isWalking, playTint, !!isMe);
+ /* รองรับทั้ง และ canvas จากย้อมสี — กล่อง W×H ตาม footprint; อนุญาต upscale เลเยอร์เล็ก (ห้ามจำกัด scale≤1 ไม่งั้นตัวหดเป็นจุด) */
+ let iw = 0, ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth > 0 && charImg.naturalHeight > 0) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ let halfSpriteW = r;
+ let spriteTopY = sy - 2 * r;
+ if (iw > 0 && ih > 0) {
+ const scale = Math.min(boxW / iw, boxH / ih);
+ let drawW = iw * scale;
+ let drawH = ih * scale;
+ if (isQuiz()) {
+ const minPx = Math.max(28, r * 1.85);
+ if (drawW < minPx || drawH < minPx) {
+ const up = Math.max(minPx / Math.max(drawW, 0.01), minPx / Math.max(drawH, 0.01));
+ drawW *= up;
+ drawH *= up;
+ }
+ }
+ halfSpriteW = drawW / 2;
+ spriteTopY = sy - drawH;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
+ } else {
+ const cy = sy - r;
+ ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#c0caf5';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ spriteTopY = cy - r;
+ }
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ const nameBase = name || '';
+ /* gauntlet (Last Light) live: ชื่อ/คะแนน/-10 ใช้ DOM overlay (syncGauntletNameDom) — ไม่วาดบน canvas */
+ /* crown map (Minigame-2): ไม่วาดชื่อ/คะแนน/-10 ใต้ตัวละครบน canvas "ทุกเฟส" (ตรง mock — โชว์เฉพาะแถบคะแนนบน)
+ เดิมเช็คเฉพาะ phase==='live' → ตอน summary/howto canvas วาดชื่อกลับมา ทับแถบคะแนน (ดูเหมือน avatar ซ้อน) */
+ const gntDomLabel = isGauntletCrownHeistMapPlay();
+ const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
+ ? (nameBase + ' · ' + gauntletScoreLabel)
+ : nameBase;
+ if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10);
+ if (!gntDomLabel
+ && isGauntletCrownHeistMapPlay()
+ && gauntletCrownPregamePhase === 'live'
+ && typeof crownPenaltyFxUntil === 'number'
+ && crownPenaltyFxUntil > timeMs) {
+ const penImg = ensureGauntletCrownScorePenaltyImgPlay();
+ if (penImg && penImg.complete && penImg.naturalWidth > 0) {
+ const ref = Math.max(18, r * 2);
+ const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1));
+ const dw = penImg.naturalWidth * scale;
+ const dh = penImg.naturalHeight * scale;
+ ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh);
+ }
+ }
+ if (isQuizCarry() && quizCarrySignPayload) {
+ drawQuizCarryHeldSignBoard(
+ sx, sy, halfSpriteW, spriteTopY,
+ quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
+ quizCarrySignPayload.imageUrl,
+ quizCarrySignPayload.imageElement,
+ );
+ }
+ }
+ function quizCarrySignForEntity(ent) {
+ if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
+ const idx = ent.quizCarryHeld;
+ const ch = quizCarryCurrent.choices;
+ if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
+ const t = String(ch[idx] != null ? ch[idx] : '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
+ const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
+ if (!t && !imgUrl && !gridHeldEl) return null;
+ return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
+ }
+ const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ function peerVisualOffset(id) {
+ let h = 0;
+ for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
+ return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
+ }
+ function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
+ if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
+ if (!ent || ent.gauntletEliminated) return true;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ax = safeX(ent.x) + (offAx || 0);
+ const ay = safeY(ent.y) + (offAy || 0);
+ const airTicks = isMeFlag ? (meGauntletJumpTicks || 0) : (Number(ent.gauntletJumpTicks) || 0);
+ const airVis = isMeFlag ? (meGauntletJumpVis || 0) : (Number(ent.gauntletJumpVis) || 0);
+ const air = airVis > 0.08 ? airVis : airTicks;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const pad = Math.max(28, r * 0.4);
+ if (sx + r + pad < 0 || sx - r - pad > canvas.width) return true;
+ if (sy + 52 < 0 || sy - r * 4 - pad > canvas.height) return true;
+ return false;
+ }
+ const { ch: sortCh } = getCharacterFootprintWH(mapData);
+ /** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */
+ function avatarDrawDepth(ent) {
+ return Math.floor(safeY(ent.y)) + sortCh;
+ }
+ if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
+ let mt = me.playTint;
+ if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') {
+ mt = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')));
+ me.playTint = mt;
+ }
+ const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
+ const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const meWalking = isFrogger()
+ ? false
+ : (isGauntlet()
+ ? ((crownRunStripLive && !me.gauntletEliminated) || meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08)
+ : !!me.isWalking);
+ const drawRows = [];
+ others.forEach((o, id) => {
+ if (!o) return;
+ drawRows.push({ isMe: false, id, o });
+ });
+ drawRows.push({ isMe: true, id: '__me', o: me });
+ drawRows.sort((a, b) => {
+ const da = avatarDrawDepth(a.o);
+ const db = avatarDrawDepth(b.o);
+ if (da !== db) return da - db;
+ const xa = Math.floor(safeX(a.o.x) * 4);
+ const xb = Math.floor(safeX(b.o.x) * 4);
+ if (xa !== xb) return xa - xb;
+ const sa = a.isMe ? '\uFFFFme' : String(a.id);
+ const sb = b.isMe ? '\uFFFFme' : String(b.id);
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
+ });
+ drawRows.forEach((row) => {
+ if (!row.isMe) {
+ const id = row.id;
+ const o = row.o;
+ const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
+ const otherWalk = isGauntlet()
+ ? ((crownRunStripLive && !o.gauntletEliminated) || (o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
+ : (isPreviewBotId(id)
+ ? !!o.botIsWalking
+ : !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
+ const ot = o.playTint || resolvePlayTintForPeer(id);
+ const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction;
+ const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
+ const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
+ const axO = safeX(o.x) + off.ax;
+ const ayO = safeY(o.y) + off.ay;
+ /* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */
+ const ghostActiveO = isQuizWrongGhostActiveForEnt(id);
+ if (!ghostActiveO && playQuizAvatarHideWhileOverlappingAnswerZone(o, id, axO, ayO)) return;
+ if (botOut) ctx.save();
+ if (botOut) ctx.globalAlpha = 0.4;
+ /* ตอบผิดในแมป mng8a80o → แทน avatar ด้วยผีเลย */
+ if (ghostActiveO) {
+ const ga = _quizGhostScreenAnchorPlay(axO, ayO, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(ga.sx, ga.sy, ga.sizeH, id, peerName);
+ } else {
+ drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(o),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (botOut) ctx.restore();
+ } else {
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
+ const axMe = safeX(me.x);
+ const ayMe = safeY(me.y);
+ const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId);
+ if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
+ const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
+ if (ghostActiveMe) {
+ const gam = _quizGhostScreenAnchorPlay(axMe, ayMe, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(gam.sx, gam.sy, gam.sizeH, myId, me.nickname + meTag);
+ } else {
+ drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(me),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
+ }
+ });
+ /* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
+ try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
+ }
+ /* ไอคอนคำถามพิเศษในฉาก (วาดทับ map/ผู้เล่น) + ตรวจการเดินชน */
+ if (specialQuizIcon) {
+ drawSpecialQuizIconWorld(worldToScreen, zDraw);
+ checkSpecialQuizCollision();
+ }
+ if (isFrogger()) {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
+ }
+ if (isJumpSurvive() && !useCyberPlayHud()) {
+ ctx.fillStyle = 'rgba(148, 226, 213, 0.95)';
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'left';
+ if (jumpSurviveEliminated) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.95)';
+ ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22);
+ } else {
+ ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22);
+ }
+ }
+ if (isGauntlet() && !useCyberPlayHud()) {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ if (isGauntletCrownHeistMapPlay()) {
+ /* Minigame-2 (Last Light): ใช้ cyber HUD + DOM overlay แล้ว — ไม่วาด debug text (คะแนน · ชน -10 · ขอบซ้ายตาย) บน canvas
+ (เดิมโผล่มุมซ้ายบนทับ SCORE label เวลา useCyberPlayHud() = false ใน preview embed) */
+ } else {
+ ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42);
+ }
+ ctx.fillStyle = '#e0af68';
+ if (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) {
+ if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ const clock = `${mm}:${String(ss).padStart(2, '0')}`;
+ ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
+ } else {
+ ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
+ }
+ }
+ }
+ if (isStack() && stackMini) {
+ if (playBotsEnabled()) {
+ if (!playHideTopChrome()) {
+ ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
+ ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
+ }
+ /* Tower mnn93hpi: แผงขวา (ชีวิตทีม + อวตาร์) วาดบน canvas — ต้องวาดแม้ซ่อนแถบห้องบน */
+ if (!playHideTopChrome() || (isStackTowerMissionUiMapPlay() && useCyberPlayHud())) {
+ drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
+ }
+ } else {
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = 'bold 15px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
+ }
+ }
+ if (isQuiz() || isQuizCarry() || isQuizBattle()) {
+ syncPlayQuizMapPanel();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+ /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */
+ syncQuizCarryTimeupDeskLayerPosition();
+ if (playBotsEnabled() && mapData && !useCyberPlayHud()) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = 'rgba(26,27,38,0.78)';
+ const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
+ ctx.fillRect(6, canvas.height - 38, hudW, 30);
+ ctx.fillStyle = '#a9b1d6';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'left';
+ const botTarget = playBotTargetHeadcount();
+ let line = detectiveCaseFillBots
+ ? ('คดีสืบสวน: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)')
+ : ('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)');
+ if (isStack()) {
+ line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
+ }
+ ctx.fillText(line, 12, canvas.height - 18);
+ ctx.restore();
+ }
+ if (isSpaceShooter()) {
+ drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isBalloonBoss()) {
+ drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isQuizCarry()) {
+ drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (quizCarryEmbedMissionFlowActive()
+ && (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) {
+ syncQuizCarryEmbedCountdownLayout();
+ }
+ drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs);
+ syncPlayCyberHud();
+ syncQuizQuestionMissionJoystickPlay();
+ syncQuizCarryGrabButton();
+ syncGauntletCrownJumpButton();
+ syncStackTowerDropButtonPlay();
+ syncStackTowerMissionDomHudPlay();
+ syncSpaceShooterLifeDomHudPlay();
+ syncQuizBattleHudPlay();
+ }
+
+ document.addEventListener('keydown', (e) => {
+ /* Test Mode shortcut: Ctrl+Q = ตอบถูกใน special quiz (server เติมคำตอบให้) */
+ if (e.ctrlKey && (e.code === 'KeyQ' || e.key === 'q' || e.key === 'Q')) {
+ if (specialQuizActiveQuestion && !specialQuizAnswered && __isTestModeOnPlay()) {
+ e.preventDefault();
+ specialQuizAnswered = true;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '[Test] ส่งคำตอบที่ถูกอัตโนมัติ — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) Array.prototype.forEach.call(choicesEl.children, function (b) { b.disabled = true; });
+ try { socket.emit('special-quiz-answer', { debugCorrect: true }, function () {}); } catch (eQ) { /* ignore */ }
+ return;
+ }
+ }
+ if (isMovementKey(e.code) && isChatFocused()) return;
+ if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) {
+ if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, playEmbedUserZoomMul / PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ if (
+ e.code === 'BracketRight'
+ || e.code === 'NumpadAdd'
+ || (e.code === 'Equal' && e.shiftKey)
+ ) {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ }
+ if (isSpaceShooter() && mapData && !isChatFocused() && isSpaceShooterMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isQuiz() && mapData && !isChatFocused() && isQuizQuestionMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isStack() && mapData && !isChatFocused() && isStackTowerMissionPregameBlockingPlay()) {
+ if (['Space', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+ if (isStack() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'Enter' || e.code === 'NumpadEnter') {
+ e.preventDefault();
+ tryHumanStackDrop();
+ return;
+ }
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isGauntlet() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ tryRequestGauntletJumpPlay();
+ return;
+ }
+ if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isJumpSurvive() && mapData && !isChatFocused()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (jumpSurviveEliminated) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ if (!e.repeat) jumpSurviveJumpQueued = true;
+ return;
+ }
+ if (e.code === 'ArrowDown' || e.code === 'KeyS') {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isQuizBattle() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyE') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
+ return;
+ }
+ }
+ if (isQuizCarry() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyF') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ return;
+ }
+ }
+ keys[e.code] = true;
+ keys[e.key] = true;
+ if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
+ if (isFrogger() && mapData && !isChatFocused()) {
+ const now = Date.now();
+ if (now - lastFroggerKey < 280) return;
+ let dx = 0, dy = 0;
+ if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
+ else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
+ else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
+ else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
+ if (dx !== 0 || dy !== 0) {
+ const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
+ if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
+ const row = mapData.objects && mapData.objects[ny];
+ if (!row || row[nx] !== 1) {
+ me.x = nx; me.y = ny;
+ lastFroggerKey = now;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ const lane = getLane(Math.floor(me.y));
+ if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
+ else if (checkFroggerCollision()) respawnFrogger();
+ }
+ }
+ }
+ }
+ });
+ document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
+
+ let lastSend = 0;
+ function tick() {
+ if (!mapData) { requestAnimationFrame(tick); return; }
+ /* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้
+ ดันเวลาทุกตัวไปข้างหน้าเท่า delta ทุกเฟรม → countdown ที่แสดงหยุดนิ่งจริง */
+ if (specialQuizFreeze) {
+ tickSpecialQuizFreezeShift();
+ if (me) me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isStack()) {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ const nowStBg = performance.now();
+ const dtStBg = Math.min(0.06, (nowStBg - lastStackTowerScrollBgTickMs) / 1000);
+ lastStackTowerScrollBgTickMs = nowStBg;
+ tickStackTowerScrollBg(dtStBg);
+ if (isStackTowerMissionPregameBlockingPlay()) {
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ stackTickFrame();
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isJumpSurvive()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ jumpSurviveTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isSpaceShooter()) {
+ if (isSpaceShooterMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ spaceShooterTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isBalloonBoss()) {
+ balloonBossTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isFrogger()) {
+ if (checkFroggerCollision()) respawnFrogger();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isGauntlet()) {
+ {
+ const nowBg = performance.now();
+ const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016);
+ lastGauntletCrownRunwayBgTickMs = nowBg;
+ tickGauntletCrownRunwayBgPlay(dtBg);
+ }
+ if (isGauntletCrownPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()) || meGauntletJumpTicks > 0;
+ const stepGauntletJumpVis = (vis, tgt) => {
+ const t = Number(tgt) || 0;
+ let v = Number(vis) || 0;
+ const d = t - v;
+ const k = d < 0 ? 0.68 : 0.4;
+ return v + d * k;
+ };
+ meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
+ const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
+ me.x = mp.nx;
+ me.y = mp.ny;
+ others.forEach((o) => {
+ const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
+ o.x = op.nx;
+ o.y = op.ny;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
+ o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
+ });
+ clampPlayEntityFootprintToMap(me, mapData);
+ others.forEach((o) => {
+ if (o) clampPlayEntityFootprintToMap(o, mapData);
+ });
+ let zGa = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zGa *= playEmbedUserZoomMul;
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa);
+ if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa);
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
+ others.forEach((o, id) => {
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * LERP;
+ if (o.ty != null) o.y += (o.ty - o.y) * LERP;
+ });
+ tickQuizCarryEmbedCountdown();
+ tickQuizCarryRoundTimers();
+ updateQuizCarryCarryPhaseHud();
+ stepPreviewBots();
+ if (isChatFocused()) {
+ me.isWalking = false;
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) {
+ me.isWalking = false;
+ playPath = [];
+ enforceQuizBattleLaneOnMePlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let accX = 0, accY = 0;
+ let usePath = false;
+ const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD'];
+ if (playPath.length > 0 && keyPressed) playPath = [];
+ if (playPath.length > 0) {
+ const way = playPath[0];
+ const dx = way.x - me.x, dy = way.y - me.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ playPath.shift();
+ /* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */
+ while (playPath.length > 0) {
+ const w2 = playPath[0];
+ const ux = w2.x - me.x, uy = w2.y - me.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ playPath.shift();
+ }
+ if (playPath.length === 0) {
+ me.isWalking = false;
+ clampPlayEntityFootprintToMap(me, mapData);
+ 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;
+ }
+ usePath = true;
+ const next = playPath[0];
+ accX = next.x - me.x;
+ accY = next.y - me.y;
+ } else {
+ usePath = true;
+ accX = dx;
+ accY = dy;
+ }
+ if (usePath && (accX !== 0 || accY !== 0)) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ if (!usePath) {
+ let kx = 0;
+ let ky = 0;
+ if (keys['ArrowLeft'] || keys['KeyA']) kx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) kx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) ky -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) ky += 1;
+ let jx = 0;
+ let jy = 0;
+ if (isQuizQuestionMissionHudActivePlay()) {
+ jx = quizQuestionMissionJoyVecX;
+ jy = quizQuestionMissionJoyVecY;
+ }
+ accX = kx + jx;
+ accY = ky + jy;
+ if (accX !== 0 || accY !== 0) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ const preWalkX = me.x, preWalkY = me.y;
+ if (accX !== 0 || accY !== 0) {
+ const len = Math.sqrt(accX * accX + accY * accY) || 1;
+ const step = Math.min(moveSpeedTilesThisFrameForWalk(), len);
+ const tox = me.x + (accX / len) * step;
+ const toy = me.y + (accY / len) * step;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(me.x, me.y, tox, toy, (nx, ny, ox, oy) => canWalkLikeLobby(nx, ny, ox, oy));
+ me.x = moved.x;
+ me.y = moved.y;
+ } else if (canWalkLikeLobby(tox, toy, me.x, me.y)) {
+ me.x = tox;
+ me.y = toy;
+ } else {
+ if (canWalkLikeLobby(tox, me.y, me.x, me.y)) {
+ me.x = tox;
+ } else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) {
+ me.y = toy;
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(me, mapData);
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5;
+ 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);
+ }
+
+ const chatForm = document.getElementById('chat-form');
+ if (chatForm) {
+ chatForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const input = document.getElementById('chat-input');
+ const text = (input && input.value || '').trim();
+ if (text) { socket.emit('chat', text); if (input) input.value = ''; }
+ });
+ }
+ document.getElementById('btn-leave').addEventListener('click', () => {
+ socket.emit('leave-space');
+ const mid = params.get('map');
+ if (previewMode && mid) {
+ var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : '');
+ window.location.replace(BASE + '/editor.html' + edQ);
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ });
+ (function wireQuizBattleHudButtons() {
+ /* EXIT → ออกห้อง กลับหน้าเลือกห้อง Quiz Battle */
+ const exitBtn = document.getElementById('qb-hud-exit');
+ if (exitBtn) {
+ exitBtn.addEventListener('click', () => {
+ try { saveQuizBattleProgress(); } catch (e) { /* ignore */ }
+ try { socket.emit('leave-space'); } catch (e) { /* ignore */ }
+ let href = '../Quiz-Battle/';
+ try { if (typeof appPath === 'function') href = appPath('/Quiz-Battle/'); } catch (e2) { /* ignore */ }
+ window.location.href = href;
+ });
+ }
+ /* MY RESULT → popup ผลตอบของฉัน */
+ const myResultBtn = document.getElementById('qb-hud-myresult');
+ if (myResultBtn) myResultBtn.addEventListener('click', function () { openQuizBattleMyResult(); });
+ /* RANKING → popup อันดับ (global leaderboard) */
+ const rankingBtn = document.getElementById('qb-hud-ranking');
+ if (rankingBtn) rankingBtn.addEventListener('click', function () { openQuizBattleRanking(); });
+ /* ZOOM เข้า/ออก ในฉาก */
+ const applyQbZoom = function (mul) {
+ quizBattleUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * mul));
+ try { draw(); } catch (e) { /* ignore */ }
+ };
+ const zoomInBtn = document.getElementById('qb-hud-zoom-in');
+ if (zoomInBtn) zoomInBtn.addEventListener('click', function () { applyQbZoom(1.2); });
+ const zoomOutBtn = document.getElementById('qb-hud-zoom-out');
+ if (zoomOutBtn) zoomOutBtn.addEventListener('click', function () { applyQbZoom(1 / 1.2); });
+ /* ปิด popup: ปุ่ม X / backdrop (data-qb-close) */
+ ['qb-myresult-popup', 'qb-ranking-popup', 'qb-completed-popup'].forEach(function (pid) {
+ const pop = document.getElementById(pid);
+ if (!pop) return;
+ pop.addEventListener('click', function (e) {
+ const t = e.target;
+ if (t && t.getAttribute && t.getAttribute('data-qb-close')) {
+ closeQuizBattlePopup(t.getAttribute('data-qb-close'));
+ }
+ });
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') { closeQuizBattlePopup('myresult'); closeQuizBattlePopup('ranking'); closeQuizBattlePopup('completed'); }
+ });
+ })();
+ (function wireQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (!ov) return;
+ ov.addEventListener('click', (e) => {
+ const t = e.target;
+ if (!t) return;
+ const choice = t.closest ? t.closest('.quiz-battle-choice') : null;
+ if (choice) {
+ const idx = parseInt(choice.getAttribute('data-idx'), 10);
+ if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx);
+ return;
+ }
+ if (t.id === 'quiz-battle-mcq-close') {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+ });
+ })();
+ (function wireQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ if (!isQuizCarry() || !mapData || isChatFocused()) return;
+ if (!btn.classList.contains('quiz-carry-grab-btn--active')) return;
+ ev.preventDefault();
+ if (myId == null) return;
+ tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ });
+ })();
+
+ (function wireGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (isChatFocused()) return;
+ /* MG5 jumper: click = queue jump (mirror keyboard Space/W/↑) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) { jumpSurviveJumpQueued = true; return; }
+ if (!mapData || !isGauntlet()) return;
+ tryRequestGauntletJumpPlay();
+ });
+ })();
+
+ (function wireStackTowerDropButton() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (!mapData || !isStack() || isChatFocused()) return;
+ if (!btn.classList.contains('stack-tower-drop-btn--active')) return;
+ tryHumanStackDrop();
+ });
+ })();
+
+ (function wireQuizQuestionMissionJoystick() {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (!base) return;
+ if (bg) {
+ bg.src = questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = 'question';
+ bg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ if (knobImg) {
+ knobImg.src = questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = 'question';
+ knobImg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ function endJoy(e) {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ try {
+ base.releasePointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ base.addEventListener('pointerdown', (e) => {
+ if (!(isQuizQuestionMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()) || isChatFocused()) return;
+ if (e.button != null && e.button !== 0) return;
+ e.preventDefault();
+ try {
+ base.setPointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = e.pointerId;
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointermove', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ e.preventDefault();
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointerup', endJoy);
+ base.addEventListener('pointercancel', endJoy);
+ base.addEventListener('lostpointercapture', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ });
+ })();
+
+ (function wireGauntletCrownHowtoPrimary() {
+ function onHowtoPrimaryClick() {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ if (!(humans.length === 1 || isMePlayHost())) return;
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'howto') return;
+ const humansSs = quizCarryPregameHumanIds();
+ const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSs === 1) {
+ if (!(humansSs.length === 1 || isMePlayHost())) return;
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSs = String(myId);
+ const nextSs = !gauntletCrownLobbyReadyMap[sidSs];
+ gauntletCrownLobbyReadyMap[sidSs] = nextSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSs });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySs = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySs) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSs = String(myId);
+ const nextHostSs = !gauntletCrownLobbyReadyMap[sidHostSs];
+ gauntletCrownLobbyReadyMap[sidHostSs] = nextHostSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSs });
+ return;
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase !== 'howto') return;
+ const humansSt = quizCarryPregameHumanIds();
+ const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSt === 1) {
+ if (!(humansSt.length === 1 || isMePlayHost())) return;
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSt = String(myId);
+ const nextSt = !gauntletCrownLobbyReadyMap[sidSt];
+ gauntletCrownLobbyReadyMap[sidSt] = nextSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySt) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSt = String(myId);
+ const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt];
+ gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt });
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownPregamePhase !== 'howto') return;
+ if (myId == null || !isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ if (humans.length === 1 && isMePlayHost()) {
+ beginGauntletCrownCountdownThenRun();
+ return;
+ }
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
+ if (!r || !r.ok) { /* ignore */ }
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateGauntletCrownHowtoHud();
+ socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ }
+ ['btn-gch-ready', 'btn-gch-ready-mg2'].forEach((id) => {
+ const btn = document.getElementById(id);
+ if (btn) btn.addEventListener('click', onHowtoPrimaryClick);
+ });
+ })();
+
+ (function wireQuizCarryPregameOverlay() {
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ if (!primary) return;
+ primary.addEventListener('click', () => {
+ if (!quizCarryPregameActive || myId == null) return;
+ if (!isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ if (humansReady) {
+ /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */
+ if (humans.length === 1 && isMePlayHost()) {
+ endQuizCarryEmbedPregameAndStart();
+ return;
+ }
+ socket.emit('quiz-carry-lobby-start', {}, (r) => {
+ if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false);
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !quizCarryLobbyReadyMap[sid];
+ quizCarryLobbyReadyMap[sid] = next;
+ updateQuizCarryPregameHud();
+ socket.emit('quiz-carry-lobby-ready', { ready: next });
+ });
+ })();
+ window.addEventListener('beforeunload', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } socket.emit('leave-space'); });
+ window.addEventListener('resize', () => {
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ });
+ /** แคนวาส flex ขยายทีหลัง (จอใหญ่ / editor iframe) — ResizeObserver จับได้ดีกว่า resize อย่างเดียว */
+ (function setupPlayCanvasStackResizeObserver() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack || typeof ResizeObserver === 'undefined') return;
+ let roRaf = null;
+ const run = () => {
+ roRaf = null;
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ };
+ const ro = new ResizeObserver(() => {
+ if (roRaf) cancelAnimationFrame(roRaf);
+ roRaf = requestAnimationFrame(run);
+ });
+ ro.observe(stack);
+ const wrap = stack.closest('.game-wrap');
+ if (wrap) ro.observe(wrap);
+ const stageEl = document.getElementById('play-canvas-stage');
+ if (stageEl) ro.observe(stageEl);
+ })();
+})();
diff --git a/www/html/Game/public/js/play.js.bak-mg4txt-103311 b/www/html/Game/public/js/play.js.bak-mg4txt-103311
new file mode 100644
index 0000000..8425156
--- /dev/null
+++ b/www/html/Game/public/js/play.js.bak-mg4txt-103311
@@ -0,0 +1,23209 @@
+(function () {
+ const BASE = '/Game';
+ function playDesignMainMenuHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') {
+ return globalThis.designMainMenuHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Menu/';
+ }
+ function playDesignMainLobbyHref() {
+ try {
+ if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') {
+ return globalThis.designMainLobbyHref();
+ }
+ } catch (e) { /* ignore */ }
+ return '/Main-Lobby/';
+ }
+ /** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */
+ const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/';
+ /** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */
+ const QUIZ_CARRY_MAP_FEEDBACK_IMG = {
+ correct: BASE + '/img/quiz-carry/icon-Correct.png',
+ incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png',
+ scorePlus: BASE + '/img/quiz-carry/score+10.png',
+ };
+ const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400;
+ const params = new URLSearchParams(window.location.search);
+ const spaceId = params.get('space');
+ function readStoredDisplayName() {
+ try {
+ return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim();
+ } catch (e) { return ''; }
+ }
+ let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น';
+ const previewMode = params.get('preview') === '1';
+ const specialQuizForceDebug = params.get('sqForce') === '1';
+ const forceDefaultCharacter = params.get('defaultChar') === '1';
+ const editorEmbedReturn = params.get('editorEmbed') === '1';
+ if (previewMode && editorEmbedReturn) {
+ try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ }
+ }
+ const playMapIdFromQuery = (params.get('map') || '').trim();
+ /** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
+ let playSessionMapId = playMapIdFromQuery;
+ /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
+ const quizCarryMissionSummaryMapId = 'mnorwqx1';
+ const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
+ /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
+ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
+ /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
+ const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
+ /** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */
+ const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
+ /** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */
+ const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o';
+ /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */
+ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
+ /** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */
+ const STACK_TOWER_FIXED_RENDER_W = 1920;
+ const STACK_TOWER_FIXED_RENDER_H = 1080;
+ /** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */
+ const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280;
+ /** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */
+ const STACK_TOWER_ROPE_DRAW_T1 = 1.0;
+ /** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */
+ const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25;
+ /** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */
+ const STACK_TOWER_POST50_PROGRESS_THRESH = 60;
+ const STACK_TOWER_POST50_ZOOM_MUL = 1.1;
+ /** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */
+ const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1;
+ const STACK_TOWER_POST50_ANIM_MS = 680;
+ /** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */
+ const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5;
+ /** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */
+ const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25;
+ /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */
+ const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
+ /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */
+ const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9;
+ /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */
+ const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14;
+ const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4;
+ const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3;
+ /** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */
+ const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38;
+ /** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */
+ const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0;
+ /** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */
+ const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62;
+ /** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */
+ const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314;
+ /** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */
+ const STACK_TOWER_POST50_WIDTH_MULT = 0.6;
+
+ function stackBlockWidthTilesForPlay(baseTiles, heavy) {
+ const b = Number(baseTiles);
+ const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2;
+ const towerHeavyNarrow =
+ !!heavy &&
+ isStackTowerMissionUiMapPlay() &&
+ stackTowerMissionPhase === 'live' &&
+ stackMini &&
+ Number.isFinite(Number(stackMini.progressPct)) &&
+ Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH;
+ if (towerHeavyNarrow) {
+ const w = base * STACK_TOWER_POST50_WIDTH_MULT;
+ return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200));
+ }
+ if (!heavy) return base;
+ const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT;
+ return Math.min(3.2, Math.round(w * 200) / 200);
+ }
+ /** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */
+ const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3;
+ const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400;
+ const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12;
+ /** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */
+ const SPACE_SHOOTER_ASTEROID_MAX_HP = 5;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11;
+
+ function spaceShooterAsteroidRadiusFromHpPlay(hp) {
+ const cap = SPACE_SHOOTER_ASTEROID_MAX_HP;
+ const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0));
+ const frac = h <= 0 ? 0 : h / cap;
+ return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP
+ + (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac;
+ }
+
+ function spaceShooterShipFootprintCellsPlay(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ return {
+ cw: Math.max(1, Math.min(4, cw)),
+ ch: Math.max(1, Math.min(4, ch)),
+ };
+ }
+
+ /** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */
+ function spaceShooterShipBodyScreenPxPlay(zDrawVal) {
+ const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1);
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const kw = 14 / 32;
+ const kh = 22 / 32;
+ return {
+ bodyW: cw * ts * zD * kw,
+ bodyH: ch * ts * zD * kh,
+ };
+ }
+
+ /** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */
+ function spaceShooterShipHitRadiusWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const ts = tileSize || 32;
+ const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
+ const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
+ const geom = Math.sqrt(Math.max(1, cw * ch));
+ const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts));
+ return Math.max(9, Math.min(cap, base * geom));
+ }
+
+ /** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */
+ function spaceShooterShipEdgePadWorldPxPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return 18;
+ const ts = tileSize || 32;
+ const { cw } = spaceShooterShipFootprintCellsPlay(mapData);
+ return Math.max(18, cw * ts * 0.48);
+ }
+
+ function spaceShooterResetMissionShipStateForAllPlay() {
+ function resetEnt(ent) {
+ if (!ent) return;
+ ent.spaceShooterHits = 0;
+ ent.spaceShooterEliminated = false;
+ ent.spaceShooterInvulnUntil = 0;
+ }
+ resetEnt(me);
+ others.forEach(function (o) {
+ resetEnt(o);
+ });
+ }
+
+ function spaceShooterMissionResolveShipAsteroidHitsPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return;
+ const now = performance.now();
+ const shipR = spaceShooterShipHitRadiusWorldPxPlay();
+
+ function tryHit(ent, cx, cy) {
+ if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return;
+ if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return;
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ if (!a) continue;
+ const dx = cx - a.x;
+ const dy = cy - a.y;
+ const rr = (a.r + shipR) * (a.r + shipR);
+ if (dx * dx + dy * dy > rr) continue;
+ ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1;
+ ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS;
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(ai, 1);
+ spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 });
+ if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) {
+ ent.spaceShooterEliminated = true;
+ spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 });
+ }
+ break;
+ }
+ }
+
+ if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy);
+ others.forEach(function (o) {
+ if (!o) return;
+ tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
+ });
+ if (spaceShooterMissionEveryParticipantEliminatedPlay()) {
+ endSpaceShooterMissionRound('all_dead');
+ }
+ }
+
+ /** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */
+ function spaceShooterMissionEveryParticipantEliminatedPlay() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false;
+ if (myId == null) return false;
+ if (!me.spaceShooterEliminated) return false;
+ let anyAlive = false;
+ others.forEach(function (o) {
+ if (!o) return;
+ if (!o.spaceShooterEliminated) anyAlive = true;
+ });
+ return !anyAlive;
+ }
+
+ /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
+ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest';
+ let firstCharacterDefaultResolved = null;
+ /** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */
+ let playCharacterIdRoster = [];
+ const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (c && c.id) playCharacterIdRoster.push(String(c.id));
+ });
+ }
+ if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
+ firstCharacterDefaultResolved = String(list[0].id);
+ } else {
+ firstCharacterDefaultResolved = '';
+ }
+ })
+ .catch(() => {
+ firstCharacterDefaultResolved = '';
+ playCharacterIdRoster = [];
+ })
+ .finally(() => {
+ try {
+ if (playBotsEnabled() && mapData) rebalancePreviewBots();
+ } catch (e) { /* map ยังไม่พร้อม */ }
+ });
+ const lobbyLevelParam = params.get('lobbyLevel');
+ const lobbyCaseParam = params.get('case');
+ if (lobbyLevelParam || lobbyCaseParam) {
+ try {
+ window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam };
+ } catch (e) { /* ignore */ }
+ }
+
+ const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h';
+
+ function isDetectiveMinigamePlay() {
+ if (params.get('detectiveReturn') === '1') return true;
+ try {
+ return sessionStorage.getItem('detectiveMinigameReturn') === '1';
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function buildRoomLobbyReturnHref() {
+ let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID);
+ const drParam = (params.get('displayRoom') || '').trim();
+ if (drParam) {
+ h += '&displayRoom=' + encodeURIComponent(drParam);
+ } else {
+ try {
+ const dr = localStorage.getItem('lastCreatedSpaceName');
+ if (dr) h += '&displayRoom=' + encodeURIComponent(dr);
+ } catch (e) { /* ignore */ }
+ }
+ try {
+ const meta = window.__detectiveLobbyMeta;
+ if (meta && meta.level != null && String(meta.level).trim()) {
+ h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim());
+ }
+ if (meta && meta.caseId != null && String(meta.caseId).trim()) {
+ h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim());
+ }
+ } catch (e2) { /* ignore */ }
+ try {
+ if (sessionStorage.getItem('detectiveMinigameReturn') === '1') {
+ h += '&detectiveReturn=1';
+ sessionStorage.removeItem('detectiveMinigameReturn');
+ }
+ } catch (e3) { /* ignore */ }
+ if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) {
+ h += '&detectiveReturn=1';
+ }
+ return h;
+ }
+
+ function finishDetectiveMinigameAndReturnLobby() {
+ quizCarrySessionEnded = true;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
+ const go = function () {
+ window.location.href = buildRoomLobbyReturnHref();
+ };
+ if (socket && socket.connected) {
+ socket.emit('detective-minigame-finished', {}, function (res) {
+ try {
+ if (res && Array.isArray(res.myPlayerEvidence)) {
+ var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; });
+ if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence));
+ else sessionStorage.removeItem('justiceDetectiveMyEvidence');
+ }
+ } catch (e) { /* ignore */ }
+ /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */
+ if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) {
+ showInSceneEvidenceReveal(res, go);
+ } else {
+ go();
+ }
+ });
+ } else {
+ go();
+ }
+ }
+
+ /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ LobbyB) ===== */
+ var __evidenceConfigCache = null;
+ function ensureEvidenceConfigLoaded(cb) {
+ if (__evidenceConfigCache) { cb(__evidenceConfigCache); return; }
+ try {
+ fetch(BASE + '/api/evidence-cards?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __evidenceConfigCache = (j && j.cases) ? j : { cases: {} }; cb(__evidenceConfigCache); })
+ .catch(function () { cb({ cases: {} }); });
+ } catch (e) { cb({ cases: {} }); }
+ }
+
+ var EVIDENCE_RARITY_GLOW = {
+ common: 'rgba(168,180,200,.55)',
+ rare: 'rgba(94,170,255,.7)',
+ legendary: 'rgba(255,186,72,.85)',
+ };
+ var EVIDENCE_RARITY_LABEL = { common: 'การ์ดธรรมดา', rare: 'การ์ด Rare', legendary: 'การ์ดชี้คนร้าย' };
+
+ /** กล่องดูการ์ดหลักฐานแบบขยาย (zoom) — ปิดด้วยปุ่ม ×, คลิกพื้นหลัง, หรือ Esc */
+ function openEvidenceCardLightboxPlay(imageUrl, label, rarity) {
+ if (!imageUrl) return;
+ var rar = String(rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var ov = document.getElementById('evidence-card-lightbox');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'evidence-card-lightbox';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(5,8,18,.88);backdrop-filter:blur(4px)';
+ ov.innerHTML =
+ '× ' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+ document.body.appendChild(ov);
+ var closeFn = function () { ov.style.display = 'none'; };
+ ov.addEventListener('click', function (e) { if (e.target === ov || (e.target.classList && e.target.classList.contains('ecl-close'))) closeFn(); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ov.style.display !== 'none') closeFn(); });
+ }
+ var img = ov.querySelector('.ecl-img');
+ var cap = ov.querySelector('.ecl-cap');
+ if (img) { img.style.boxShadow = '0 0 44px ' + glow + ',0 22px 60px rgba(0,0,0,.6)'; img.src = imageUrl; }
+ if (cap) cap.textContent = label || '';
+ ov.style.display = 'flex';
+ }
+
+ function showInSceneEvidenceReveal(res, onDone) {
+ var done = false;
+ var finish = function () { if (done) return; done = true; onDone(); };
+ var si = res.suspectIndex;
+ /* การ์ดที่เพิ่งได้รอบนี้ (object: rarity/imageUrl) — fallback ใช้ N ใบท้ายของ owned */
+ var awarded = (res.awardedCards && Array.isArray(res.awardedCards))
+ ? res.awardedCards.filter(function (c) { return c && c.imageUrl; })
+ : [];
+ if (!awarded.length) {
+ var owned = (res.myPlayerEvidence && res.myPlayerEvidence[si]) || [];
+ var freeN = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ var newN = Math.min(owned.length, 1 + freeN);
+ awarded = owned.slice(owned.length - newN).filter(function (c) { return c && c.imageUrl; });
+ }
+ if (!awarded.length) { finish(); return; }
+ var newN = awarded.length;
+ var ov = document.createElement('div');
+ ov.id = 'inscene-evidence-reveal';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;backdrop-filter:blur(3px)';
+ var title = document.createElement('div');
+ title.style.cssText = 'font:900 30px/1.2 Kanit,system-ui,sans-serif;color:#ffe7a3;text-shadow:0 0 18px rgba(255,214,102,.5);text-align:center';
+ title.textContent = newN > 1 ? ('คุณได้รับหลักฐาน ' + newN + ' ใบ!') : 'คุณได้รับหลักฐาน!';
+ if (res.grade) {
+ var gradeEl = document.createElement('div');
+ gradeEl.style.cssText = 'font:800 16px/1 Kanit,system-ui,sans-serif;color:#bfe3ff;opacity:.9';
+ gradeEl.textContent = 'ผลงานทีม: เกรด ' + res.grade;
+ title.appendChild(document.createElement('br'));
+ title.appendChild(gradeEl);
+ }
+ var freeBonus = Math.max(0, Number(res.freeEvidenceCount) || 0);
+ if (freeBonus > 0) {
+ var bonusEl = document.createElement('div');
+ bonusEl.style.cssText = 'font:800 15px/1.3 Kanit,system-ui,sans-serif;color:#9ece6a;margin-top:4px';
+ bonusEl.textContent = '⭐ การ์ด Free Evidence: ทุกคนรับหลักฐานฟรี +' + freeBonus + ' ใบ';
+ title.appendChild(document.createElement('br'));
+ title.appendChild(bonusEl);
+ }
+ var row = document.createElement('div');
+ row.style.cssText = 'display:flex;gap:18px;flex-wrap:wrap;justify-content:center;align-items:center;max-width:92vw';
+ awarded.forEach(function (c, i) {
+ var rar = String(c.rarity || 'common').toLowerCase();
+ var glow = EVIDENCE_RARITY_GLOW[rar] || EVIDENCE_RARITY_GLOW.common;
+ var cardWrap = document.createElement('div');
+ cardWrap.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;opacity:0;transform:translateY(24px) scale(.92);transition:opacity .45s ease,transform .45s cubic-bezier(.2,.9,.3,1.3)';
+ var img = document.createElement('img');
+ img.src = c.imageUrl;
+ img.alt = EVIDENCE_RARITY_LABEL[rar] || 'หลักฐาน';
+ img.style.cssText = 'display:block;width:min(34vw,260px);height:auto;border-radius:14px;box-shadow:0 0 36px ' + glow + ',0 18px 50px rgba(0,0,0,.6);cursor:zoom-in';
+ img.title = 'แตะเพื่อดูใหญ่';
+ img.addEventListener('click', function () { openEvidenceCardLightboxPlay(c.imageUrl, EVIDENCE_RARITY_LABEL[rar] || '', rar); });
+ img.onerror = function () { this.onerror = null; this.style.width = '200px'; this.style.height = '300px'; this.style.background = '#1b2240'; this.removeAttribute('src'); };
+ var tag = document.createElement('div');
+ tag.style.cssText = 'font:800 14px/1 Kanit,system-ui,sans-serif;color:#fff;opacity:.92';
+ tag.textContent = EVIDENCE_RARITY_LABEL[rar] || '';
+ cardWrap.appendChild(img);
+ cardWrap.appendChild(tag);
+ row.appendChild(cardWrap);
+ setTimeout(function () { cardWrap.style.opacity = '1'; cardWrap.style.transform = 'translateY(0) scale(1)'; }, 120 + i * 160);
+ });
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = 'คุณได้หลักฐาน · เก็บเข้าแฟ้ม';
+ btn.style.cssText = 'cursor:pointer;border:none;border-radius:999px;padding:14px 40px;font:800 18px/1 Kanit,system-ui,sans-serif;color:#0a0e1a;background:linear-gradient(180deg,#ffd666,#f0b429);box-shadow:0 10px 30px rgba(240,180,41,.45)';
+ btn.addEventListener('click', function () {
+ ov.style.transition = 'opacity .25s'; ov.style.opacity = '0';
+ setTimeout(finish, 220);
+ });
+ ov.appendChild(title);
+ ov.appendChild(row);
+ ov.appendChild(btn);
+ document.body.appendChild(ov);
+ /* กันค้าง: ถ้าไม่กดภายใน 12 วิ ให้ไปต่อเอง */
+ setTimeout(function () { if (!done) { try { ov.remove(); } catch (e) {} finish(); } }, 12000);
+ }
+
+ /** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */
+ function tryFinishDetectiveMinigameAndReturnLobby() {
+ if (!isDetectiveMinigamePlay()) return false;
+ finishDetectiveMinigameAndReturnLobby();
+ return true;
+ }
+
+ /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
+ const previewFillBots = previewMode && editorEmbedReturn;
+ let detectiveCaseFillBots = false;
+ let detectiveBotSlotCount = 0;
+ function playBotsEnabled() {
+ return previewFillBots || detectiveCaseFillBots;
+ }
+ function playBotTargetHeadcount() {
+ if (previewFillBots) return previewTargetHeadcount;
+ if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount;
+ return countPlayHumans();
+ }
+ /** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */
+ const PLAY_EMBED_USER_ZOOM_MIN = 0.5;
+ const PLAY_EMBED_USER_ZOOM_MAX = 3;
+ const PLAY_EMBED_ZOOM_STEP_KEY = 1.12;
+ let playEmbedUserZoomMul = 1;
+ let lastPlayZDrawForInput = 1.4;
+ /** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */
+ function playDomSyncZoom() {
+ const z = lastPlayZDrawForInput;
+ return Number.isFinite(z) && z > 0 ? z : zoom;
+ }
+ const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
+ const PREVIEW_BOT_PREFIX = '__pv_bot_';
+ let previewBotSeq = 0;
+ /** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
+ let lastStackPreviewActorId = null;
+ let lastStackPreviewActorUntil = 0;
+ /** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
+ const STACK_PREVIEW_TURN_COUNT = 6;
+ let stackPreviewTurnOrder = null;
+ let stackPreviewTurnIndex = 0;
+ let stackPreviewBotThinkUntil = 0;
+
+ function rebuildStackPreviewTurnOrder() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
+ stackPreviewTurnOrder = null;
+ return;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ stackPreviewTurnIndex = 0;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function advanceStackPreviewTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
+ stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ }
+
+ function isStackPreviewHumanTurn() {
+ if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return !!(cur && cur.kind === 'human');
+ }
+
+ function getStackPreviewCurrentSeat() {
+ if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
+ const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
+ return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
+ }
+
+ function getStackDropActorSeat() {
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ return getStackPreviewCurrentSeat();
+ }
+ return 1;
+ }
+
+ function markStackNextDropVisualDirty() {
+ if (stackMini) stackMini.nextDropVisualDirty = true;
+ }
+
+ function pickStackBlockHeavyVisual(seat) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
+ /** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */
+ if (isStackTowerMissionUiMapPlay()) {
+ if (!(stackTowerMissionPhase === 'live' && stackMini)) return false;
+ const p = Number(stackMini.progressPct);
+ if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false;
+ return !!hU;
+ }
+ const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0)));
+ if (pct <= 0) return false;
+ if (!hU) return false;
+ if (Math.random() * 100 >= pct) return false;
+ return true;
+ }
+
+ function ensureStackNextDropVisual() {
+ if (!stackMini || !stackMini.nextDropVisualDirty) return;
+ stackMini.pendingDropSeat = getStackDropActorSeat();
+ stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat);
+ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy);
+ stackMini.nextDropVisualDirty = false;
+ }
+
+ function normalizePlayStackSixUrlsFromTiming(arr) {
+ const out = ['', '', '', '', '', ''];
+ if (!Array.isArray(arr)) return out;
+ for (let i = 0; i < 6; i++) {
+ out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
+ if (out[i]) ensureGauntletAssetImage(out[i]);
+ }
+ return out;
+ }
+
+ function resolveStackBlockSpriteRec(seat, heavy) {
+ const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
+ const tryHeavy = !!heavy;
+ const order = tryHeavy
+ ? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]]
+ : [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]];
+ for (let ti = 0; ti < order.length; ti++) {
+ const u = normalizeGauntletAssetUrlForPlay(order[ti] || '');
+ if (!u) continue;
+ return ensureGauntletAssetImage(u);
+ }
+ return null;
+ }
+
+ /**
+ * @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback
+ * ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain
+ * เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult
+ */
+ function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) {
+ const o = opts || {};
+ const rec = resolveStackBlockSpriteRec(seat, heavy);
+ const img = rec && rec.img;
+ if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ if (drawW > 0 && drawH > 0) {
+ const scale = Math.max(drawW / iw, drawH / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = sx + (drawW - dw) * 0.5;
+ const dy = sy + (drawH - dh) * 0.5;
+ const prevSmooth = ctx.imageSmoothingEnabled;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(sx, sy, drawW, drawH);
+ ctx.clip();
+ try {
+ ctx.imageSmoothingEnabled = false;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ } finally {
+ ctx.imageSmoothingEnabled = prevSmooth;
+ }
+ ctx.restore();
+ if (o.missOverlay) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.42)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ }
+ return true;
+ }
+ }
+ const hue = ((hueFallback % 360) + 360) % 360;
+ ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)');
+ ctx.fillRect(sx, sy, drawW, drawH);
+ return false;
+ }
+
+ if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; }
+
+ const socket = io({ path: BASE + '/socket.io' });
+ window.__jdGameSocket = socket;
+ const canvas = document.getElementById('game-canvas');
+ const ctx = canvas.getContext('2d');
+ let mapData = null, tileSize = 32, myId = null;
+ let stackMini = null;
+ let stackFall = null;
+ /** เศษบล็อกที่พลาด/หล่น → ตกแบบ physics หลุดขอบจอ (visual เท่านั้น ไม่กระทบคะแนน/sync) */
+ let stackMissDebris = [];
+ let _stackDebrisLastT = 0;
+ /** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */
+ let stackTowerSlingImg = null;
+ /** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */
+ let stackTowerScorePlus10Img = null;
+ let stackTowerScorePlus20Img = null;
+ let stackTowerScoreLightImg = null;
+ let stackTowerScoreX2Img = null;
+ /** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */
+ let stackTowerHeartMinusImg = null;
+ /** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */
+ let stackTowerLifeBarImg = null;
+ let stackTowerLifeHitImg = null;
+ let stackTowerLifeFullImg = null;
+ /** Space Shooter (violent_crime): life HUD โหลดจาก /img/ViolentCrime/ แยกจาก TowerBlock ของ Stack */
+ let violentCrimeLifeBarImg = null;
+ let violentCrimeLifeHitImg = null;
+ let violentCrimeLifeFullImg = null;
+ /** @type {{ until: number } | null} */
+ let stackTowerHeartMinusFx = null;
+ const STACK_TOWER_HEART_MINUS_FX_MS = 1500;
+ const STACK_TOWER_PERFECT_GLOW_MS = 1500;
+ /** ป๊อปอัป +10 / +20 เหนือบล็อก */
+ const STACK_TOWER_SCORE_POPUP_MS = 2000;
+ /** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */
+ const STACK_TOWER_PERFECT_X2_MS = 2200;
+ /** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */
+ const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998;
+ let lastStackTickMs = performance.now();
+ let lastStackTowerScrollBgTickMs = performance.now();
+ let mapBackgroundImg = null;
+ /** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */
+ const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp';
+ const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png';
+ const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png';
+ let playScrollBgIntroImg = null;
+ let playScrollBgLoopImg = null;
+ /** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */
+ let playScrollBgPx = 0;
+ let playScrollBgSpeedPxPerSec = 56;
+ let playScrollBgOff = false;
+ /** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */
+ let playScrollBgFlowDown = false;
+
+ function playScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID);
+ }
+
+ function playScrollBgDrawActive() {
+ if (!playScrollBgMapEligible() || playScrollBgOff) return false;
+ const a = playScrollBgIntroImg;
+ const b = playScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ function reloadPlayScrollBgFromMap() {
+ playScrollBgIntroImg = null;
+ playScrollBgLoopImg = null;
+ playScrollBgPx = 0;
+ if (!playScrollBgMapEligible()) return;
+ const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {};
+ playScrollBgOff = raw.enabled === false;
+ playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56));
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
+ playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ if (playScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ playScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { playScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { playScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function playScrollBgSyncInitialScrollToBottom() {
+ const intro = playScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ /* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */
+ if (playScrollBgFlowDown) {
+ playScrollBgPx = 0;
+ } else {
+ playScrollBgPx = Math.max(0, drawHIntro - chR);
+ }
+ }
+
+ function drawPlayScrollBgFullCanvas(cw, ch) {
+ const intro = playScrollBgIntroImg;
+ const loop = playScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const S = playScrollBgPx;
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 2;
+ const flowDown = playScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ /* BG อวกาศเป็นภาพ illustration (loop 1024w สเกลขึ้น ~1.9x) — เปิด smoothing ไม่ให้แตกเป็นบล็อก (stack tower BG กว้าง ~1920 จึงคงปิดไว้) */
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ function tickPlayScrollBg(dtSec) {
+ if (!playScrollBgDrawActive()) return;
+ playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec;
+ }
+
+ /** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */
+ const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07';
+ /** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */
+ const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000;
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
+ const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
+ let gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ let gauntletCrownRunwayBgOff = false;
+ let gauntletCrownRunwayBgSpeedPxPerSec = 48;
+ let gauntletCrownRunwayBgScrollPx = 0;
+ let gauntletCrownRunwayBgFinishLatched = false;
+ /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */
+ let gauntletFinishPhaseSignaled = false;
+ /** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */
+ let gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ /** คอลัมน์แผนที่ที่ล็อกตอน freeze — แนบตัวนำแทนขอบขวาสูงสุดของแมป */
+ let gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ /** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */
+ let gauntletCrownRunwayClientMissionShown = false;
+ /** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */
+ const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8;
+ const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png';
+ let gauntletCrownScorePenaltyImg = null;
+ function ensureGauntletCrownScorePenaltyImgPlay() {
+ if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg;
+ const im = new Image();
+ im.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ gauntletCrownScorePenaltyImg = im;
+ return im;
+ }
+ let lastGauntletCrownRunwayBgTickMs = 0;
+ /** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */
+ let gauntletCrownRunwaySpawnScrollSnapped = false;
+
+ function gauntletCrownRunwayBgMapEligiblePlay() {
+ return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID);
+ }
+
+ function gauntletCrownRunwayBgNatDims(im) {
+ if (!im) return null;
+ if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height };
+ if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight };
+ return null;
+ }
+
+ function gauntletCrownRunwayBgImageSlotReadyPlay(im) {
+ return !!gauntletCrownRunwayBgNatDims(im);
+ }
+
+ function gauntletCrownRunwayBgStripWidthsAtCh(chR) {
+ const ch0 = Math.max(1, Math.round(chR));
+ const wOf = (im) => {
+ const d = gauntletCrownRunwayBgNatDims(im);
+ if (!d) return 0;
+ return Math.max(1, Math.round(d.nw * (ch0 / d.nh)));
+ };
+ return {
+ w0: wOf(gauntletCrownRunwayBgImgs[0]),
+ w1: wOf(gauntletCrownRunwayBgImgs[1]),
+ w2: wOf(gauntletCrownRunwayBgImgs[2]),
+ w3: wOf(gauntletCrownRunwayBgImgs[3]),
+ w4: wOf(gauntletCrownRunwayBgImgs[4]),
+ };
+ }
+
+ function gauntletCrownRunwayBgImagesReadyPlay() {
+ for (let i = 0; i < 5; i++) {
+ if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false;
+ }
+ return true;
+ }
+
+ /** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */
+ function ensureGauntletCrownRunwayBgPlaceholdersPlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return;
+ const W = 960;
+ const H = 540;
+ const hues = [352, 210, 165, 225, 38];
+ for (let i = 0; i < 5; i++) {
+ if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue;
+ const c = document.createElement('canvas');
+ c.width = W;
+ c.height = H;
+ const g = c.getContext('2d');
+ const h0 = hues[i];
+ const grd = g.createLinearGradient(0, 0, W, H * 0.9);
+ grd.addColorStop(0, `hsl(${h0},26%,20%)`);
+ grd.addColorStop(0.45, `hsl(${h0},18%,14%)`);
+ grd.addColorStop(1, `hsl(${h0},12%,10%)`);
+ g.fillStyle = grd;
+ g.fillRect(0, 0, W, H);
+ g.strokeStyle = 'rgba(255,255,255,0.06)';
+ g.lineWidth = 1;
+ for (let x = 0; x < W; x += 48) {
+ g.beginPath();
+ g.moveTo(x, 0);
+ g.lineTo(x + H * 0.04, H);
+ g.stroke();
+ }
+ const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85);
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
+ vg.addColorStop(1, 'rgba(0,0,0,0.38)');
+ g.fillStyle = vg;
+ g.fillRect(0, 0, W, H);
+ g.fillStyle = 'rgba(255,255,255,0.1)';
+ g.font = '10px system-ui,sans-serif';
+ g.textAlign = 'right';
+ g.fillText(String(i + 1), W - 10, H - 8);
+ gauntletCrownRunwayBgImgs[i] = c;
+ }
+ }
+
+ function gauntletCrownRunwayBgDrawActivePlay() {
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false;
+ return gauntletCrownRunwayBgImagesReadyPlay();
+ }
+
+ function gauntletCrownRunwayFinishLineInArtFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let lineInArt = Number(raw.finishLineInFinishArtFrac);
+ if (!Number.isFinite(lineInArt)) {
+ lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.34 : 0.12;
+ }
+ return Math.max(0, Math.min(0.92, lineInArt));
+ }
+
+ function gauntletCrownRunwayFinishLineStripXPlay(g) {
+ if (!g) return null;
+ return g.finishStart + g.w4Safe * gauntletCrownRunwayFinishLineInArtFracPlay();
+ }
+
+ /** โลกแมป X ของจุดอ้างอิงในงานศิลป์ FINISH = stripX − scrollPx (ไม่ขึ้นกับกล้อง) */
+ function gauntletCrownRunwayFinishLineMapWorldXPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const stripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (stripX == null) return null;
+ return stripX - gauntletCrownRunwayBgScrollPx;
+ }
+
+ function gauntletCrownRunwayStripFrozenPlay() {
+ return gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched;
+ }
+
+ /** จุดอ้างอิงกลางตัวนำ (map world px) — ใช้หยุด FINISH ตรงตัวละคร ไม่ใช่ขอบขวาแมป */
+ function gauntletCrownRunwayLeadingEntityAlignWorldXPlay() {
+ if (!mapData || !isGauntletCrownHeistMapPlay()) return null;
+ const ts = mapData.tileSize || 32;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ let leadTx = -Infinity;
+ const consider = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ let tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x);
+ if (!Number.isFinite(tx)) return;
+ leadTx = Math.max(leadTx, tx);
+ };
+ consider(me);
+ others.forEach((o, id) => {
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ consider(o);
+ });
+ if (!Number.isFinite(leadTx) || leadTx === -Infinity) return null;
+ let alignWorld = (leadTx + cw * 0.5) * ts;
+ if (mx != null && Number.isFinite(mx)) {
+ alignWorld = Math.min(alignWorld, (mx + cw * 0.5) * ts);
+ }
+ return alignWorld;
+ }
+
+
+ /**
+ * คอลัมน์บนกริดที่เส้น FINISH ต้องแนบ (map world px)
+ * gauntletCrownRunwayBg.finishAlignMapTileX | finishAlignMapTileFrac — ถ้าไม่ตั้ง ใช้ตำแหน่งตัวนำ
+ */
+ function gauntletCrownRunwayFinishAlignMapWorldXPlay() {
+ if (!mapData) return null;
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) {
+ return gauntletCrownRunwayFinishAlignLatchedWorldX;
+ }
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const ts = mapData.tileSize || 32;
+ const tileX = Number(raw.finishAlignMapTileX);
+ if (Number.isFinite(tileX)) return (tileX + 0.5) * ts;
+ const tileFrac = Number(raw.finishAlignMapTileFrac);
+ if (Number.isFinite(tileFrac)) {
+ const w = mapData.width || 20;
+ return (Math.max(0, Math.min(1, tileFrac)) * w + 0.5) * ts;
+ }
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ return gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ }
+ return null;
+ }
+
+ /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */
+ function gauntletCrownRunwayFinishLineScreenFracPlay(g) {
+ if (!g) return null;
+ const finishLineWorldX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineWorldX == null) return null;
+ return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld);
+ }
+
+ function gauntletCrownRunwayFinishStopViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75;
+ let vp = Number(raw.finishStopViewportFrac);
+ if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp));
+ let legacy = Number(raw.finishStopScreenFrac);
+ if (Number.isFinite(legacy)) {
+ legacy = Math.max(0.08, Math.min(0.96, legacy));
+ return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42));
+ }
+ /* mno9kb07: หยุดเลื่อนเร็วขึ้น (เส้น FINISH หยุดที่ ~0.55 จากซ้าย — ขอบขวาของแถบ FINISH ใกล้กลางจอ) */
+ return gauntletCrownRunwayBgMapEligiblePlay() ? 0.55 : 0.34;
+ }
+
+ function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() {
+ const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ let v = Number(raw.obstacleFadeStartViewportFrac);
+ /* mno9kb07: เริ่มจางอุปสรรคให้เร็วขึ้น (1.0 = ตอนเส้น FINISH ยังอยู่ขอบขวา) เพื่อให้อุปสรรคหายก่อนถึงเส้นชัย */
+ if (!Number.isFinite(v)) v = gauntletCrownRunwayBgMapEligiblePlay() ? 1.05 : 0.9;
+ return Math.max(0.45, Math.min(1.15, v));
+ }
+
+ /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */
+ function gauntletCrownRunwayObstacleDrawAlphaPlay() {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return 1;
+ if (gauntletCrownRunwayBgFinishLatched) return 0;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return 1;
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return 1;
+ const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay();
+ if (screenFrac >= fadeStart) return 1;
+ if (screenFrac <= stopAt) return 0;
+ return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt);
+ }
+
+ function gauntletCrownRunwayBgHideObstaclesPlay() {
+ return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001;
+ }
+
+ /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */
+ function gauntletCrownRunwayAvatarRunAllowedPlay() {
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) {
+ if (gauntletCrownRunwayBgFinishLatched) return false;
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false;
+ return true;
+ }
+ return true;
+ }
+
+ function drawImageCoverCanvasPlay(ctx, img, cwR, chR) {
+ const d = gauntletCrownRunwayBgNatDims(img);
+ if (!d) return;
+ const iw = d.nw;
+ const ih = d.nh;
+ if (!iw || !ih) return;
+ const scale = Math.max(cwR / iw, chR / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const dx = (cwR - dw) / 2;
+ const dy = (chR - dh) / 2;
+ ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
+ }
+
+ function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
+ const d0 = gauntletCrownRunwayBgNatDims(img);
+ if (!d0 || segW <= 0) return;
+ const natW = d0.nw;
+ const natH = d0.nh;
+ const v0 = scrollLeft;
+ const v1 = scrollLeft + cwR;
+ const o0 = Math.max(worldX0, v0);
+ const o1 = Math.min(worldX0 + segW, v1);
+ if (o1 <= o0) return;
+ const u0 = ((o0 - worldX0) / segW) * natW;
+ const u1 = ((o1 - worldX0) / segW) * natW;
+ const dx = o0 - scrollLeft;
+ const dw = o1 - o0;
+ /** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */
+ ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR);
+ }
+
+ /** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */
+ function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) {
+ const start = gauntletCrownRunwayBgImgs[0];
+ const L2 = gauntletCrownRunwayBgImgs[1];
+ const L3 = gauntletCrownRunwayBgImgs[2];
+ const L4 = gauntletCrownRunwayBgImgs[3];
+ const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ const cycle = w1 + w2 + w3;
+ const S = scrollView;
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR);
+ if (cycle <= 0) return;
+ const ws = [w1, w2, w3];
+ const ims = [L2, L3, L4];
+ const uTotal = Math.max(0, S - w0);
+ let cycN = Math.floor(uTotal / cycle);
+ let uRem = uTotal - cycN * cycle;
+ let si = 0;
+ while (si < 3 && uRem >= ws[si]) {
+ uRem -= ws[si];
+ si++;
+ }
+ if (si >= 3) {
+ cycN++;
+ si = 0;
+ uRem = 0;
+ }
+ let acc = 0;
+ for (let k = 0; k < si; k++) acc += ws[k];
+ let curLeft = w0 + cycN * cycle + acc;
+ let guard = 0;
+ let idx = si;
+ while (curLeft < S + cwR && guard++ < 500) {
+ drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
+ curLeft += ws[idx];
+ idx = (idx + 1) % 3;
+ }
+ }
+
+ /** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */
+ function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) {
+ const S = scrollView;
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
+ ctx.imageSmoothingEnabled = true;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, cwR, chR);
+ const segs = [
+ { im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 },
+ { im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 },
+ { im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 },
+ { im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 },
+ { im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 },
+ ];
+ for (let i = 0; i < segs.length; i++) {
+ const g = segs[i];
+ if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR);
+ }
+ }
+
+ /**
+ * @param {number} canvasW
+ * @param {number} canvasH
+ * @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง)
+ */
+ function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ const finish = gauntletCrownRunwayBgImgs[4];
+ const align = worldAlign && typeof worldAlign === 'object'
+ && Number.isFinite(worldAlign.worldMinX)
+ && Number.isFinite(worldAlign.worldMaxX)
+ && Number.isFinite(worldAlign.camX)
+ && Number.isFinite(worldAlign.camY)
+ && Number.isFinite(worldAlign.zDraw)
+ && Number.isFinite(worldAlign.mapWpx)
+ && Number.isFinite(worldAlign.mapHpx);
+ /* หลัง latch ยังวาดแถบรันเวย์ที่ scroll ค้าง — อย่า switch เป็น cover เต็มแผนที่ (ทำให้ FINISH กระโดดซ้าย) */
+ /* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */
+ const cwR = align
+ ? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX)
+ : Math.max(1, Math.round(canvasW));
+ const chR = align
+ ? Math.max(1, Math.round(worldAlign.mapHpx))
+ : Math.max(1, Math.round(canvasH));
+ const scrollView = align
+ ? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX)
+ : gauntletCrownRunwayBgScrollPx;
+ if (align) {
+ const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign;
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvasW, canvasH);
+ ctx.save();
+ ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
+ ctx.beginPath();
+ ctx.rect(0, 0, mapWpx, mapHpx);
+ ctx.clip();
+ ctx.translate(worldMinX, 0);
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ return;
+ }
+ ctx.save();
+ if (gauntletCrownRunwayBgMapEligiblePlay()) {
+ drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
+ } else {
+ drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
+ }
+ ctx.restore();
+ }
+
+ /**
+ * Shared runway strip math (viewport in strip space). null if not drawable.
+ * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34)
+ * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12)
+ * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9)
+ */
+ function gauntletCrownRunwayScrollGeometryPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const halfW = canvas.width / (2 * zGaRaw);
+ const worldMinX = gCam.px - halfW;
+ const cwWorld = canvas.width / zGaRaw;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ const w4Safe = Math.max(w4, 1);
+ const totalStrip = w0 + w1 + w2 + w3 + w4Safe;
+ if (!(totalStrip > 8)) return null;
+ const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX;
+ const right = scrollView + cwWorld;
+ const finishStart = w0 + w1 + w2 + w3;
+ const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe);
+ const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0;
+ const minScrollForFinishPass = geoMinScroll + wideVpPad;
+ return {
+ scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass,
+ };
+ }
+
+ /** True when เส้น FINISH ถึงตำแหน่งหยุด (แนบคอลัมน์แผนที่ หรือสัดส่วนจอแบบเดิม) */
+ function gauntletCrownRunwayScrollStripStopZoneReachedPlay() {
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return false;
+ if (g.scrollView < g.minScrollForFinishPass - 0.01) return false;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay();
+ if (finishMapX == null) return false;
+ return finishMapX <= alignWorldX + 1.5;
+ }
+ const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g);
+ if (screenFrac == null) return false;
+ return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008;
+ }
+
+ /** ค่าตำแหน่ง scroll ที่ทำให้เส้น FINISH หยุดตรงจุด (คืน null ถ้าคำนวณไม่ได้) — ใช้ทั้ง snap และ clamp กันวิ่งเลยเส้นชัย */
+ function gauntletCrownRunwayFinishStopScrollPxPlay() {
+ if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
+ const g = gauntletCrownRunwayScrollGeometryPlay();
+ if (!g) return null;
+ const finishLineStripX = gauntletCrownRunwayFinishLineStripXPlay(g);
+ if (finishLineStripX == null) return null;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ return finishLineStripX - alignWorldX;
+ }
+ const stopVp = gauntletCrownRunwayFinishStopViewportFracPlay();
+ const targetScrollView = finishLineStripX - stopVp * g.cwWorld;
+ const zGaRaw = computePlayCameraZDrawPlay();
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
+ if (!gCam) return null;
+ const worldMinX = gCam.px - canvas.width / (2 * zGaRaw);
+ return targetScrollView - worldMinX;
+ }
+
+ /** จัด scroll ให้จุดอ้างอิงใน FINISH ตรงคอลัมน์แผนที่ (หรือ finishStopViewportFrac บนจอ) */
+ function gauntletCrownRunwaySnapScrollToFinishStopPlay() {
+ const t = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (t == null) return;
+ gauntletCrownRunwayBgScrollPx = t;
+ }
+
+ /** ลำดับการหยุดที่เส้นชัย: ล็อก align (ครั้งเดียว) → snap เป๊ะ → เริ่ม freeze → (latch+mission ทีหลังใน tick) */
+ function gauntletCrownRunwayBeginFinishStopPlay() {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ /* แจ้ง server ครั้งเดียว: ถึงเส้นชัยแล้ว → หยุด obstacle + collision (กัน -10 ตอน obstacle หายแล้ว) */
+ if (!gauntletFinishPhaseSignaled) {
+ gauntletFinishPhaseSignaled = true;
+ try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ }
+ }
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { draw(); } catch (_d) { /* ignore */ }
+ }
+
+ /** โหมดจอเท่านั้น: กล้องเลื่อนหลัง freeze ทำให้ FINISH หลุด — รีสแนปทุกเฟรม */
+ function gauntletCrownRunwayMaintainFinishScrollPlay() {
+ if (!gauntletCrownRunwayBgStripFreezeSinceMs && !gauntletCrownRunwayBgFinishLatched) return;
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ if (gauntletCrownRunwayFinishAlignMapWorldXPlay() != null) return;
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ }
+
+ function resetGauntletCrownRunwaySpawnScrollSnap() {
+ gauntletCrownRunwaySpawnScrollSnapped = false;
+ }
+
+ /**
+ * จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0)
+ * ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32)
+ * Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง
+ * Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand.
+ */
+ function trySnapGauntletCrownRunwayScrollToSpawnsPlay() {
+ if (gauntletCrownRunwaySpawnScrollSnapped) return;
+ if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return;
+ if (!gauntletCrownRunwayBgImagesReadyPlay()) return;
+ const h = mapData.height || 15;
+ const ts = mapData.tileSize || 32;
+ const mapHpx = Math.max(1, Math.round(h * ts));
+ const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
+ if (!(w0 > 0)) return;
+ const slots = collectGauntletSpawnSlotsPlay(mapData);
+ let minTX = Infinity;
+ if (slots.length) minTX = Math.min(...slots.map((s) => s.x));
+ if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x));
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x));
+ });
+ if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1;
+ const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32));
+ gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX;
+ gauntletCrownRunwaySpawnScrollSnapped = true;
+ }
+
+ function reloadGauntletCrownRunwayBgFromMap() {
+ gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ gauntletCrownRunwayBgScrollPx = 0;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ lastGauntletCrownRunwayBgTickMs = 0;
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
+ const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
+ gauntletCrownRunwayBgOff = raw.enabled === false;
+ gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
+ if (gauntletCrownRunwayBgOff) return;
+ const srcs = [
+ (typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START,
+ (typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2,
+ (typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3,
+ (typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4,
+ (typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH,
+ ];
+ let pending = 5;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ ensureGauntletCrownRunwayBgPlaceholdersPlay();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ for (let i = 0; i < 5; i++) {
+ const im = new Image();
+ const idx = i;
+ im.onload = () => {
+ gauntletCrownRunwayBgImgs[idx] = im;
+ bump();
+ };
+ im.onerror = () => { bump(); };
+ im.src = srcs[i];
+ }
+ }
+
+ function tickGauntletCrownRunwayBgPlay(dtSec) {
+ if (!gauntletCrownRunwayBgDrawActivePlay()) return;
+ /* หยุดเลื่อน BG ระหว่างคำถามพิเศษ (special-quiz freeze) — กัน BG/เส้นชัยเดินตอนค้างตอบ */
+ if (specialQuizFreeze) return;
+ /** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */
+ if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return;
+ if (gauntletCrownRunwayBgFinishLatched) return;
+
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) {
+ gauntletCrownRunwayBgFinishLatched = true;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) {
+ gauntletCrownRunwayClientMissionShown = true;
+ window.setTimeout(function () {
+ try {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownHowtoVisible = false;
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ showGauntletCrownMissionOverlay(gauntletCrownHeistBuildLocalCrownMissionPlay());
+ } catch (_e) { /* ignore */ }
+ try {
+ draw();
+ } catch (_e2) { /* ignore */ }
+ }, 340);
+ }
+ }
+ return;
+ }
+
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) {
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ try {
+ syncGauntletCrownJumpButton();
+ } catch (_sj) { /* ignore */ }
+ try {
+ draw();
+ } catch (_d) { /* ignore */ }
+ return;
+ }
+ const gcrInc = (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec;
+ /* ถ้าก้าวถัดไป "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น (ไม่วิ่งเลย) แล้วเริ่มจบเกมทันที */
+ const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null
+ && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01
+ && gauntletCrownRunwayBgScrollPx + gcrInc >= gcrLiveStopPx) {
+ gauntletCrownRunwayBgScrollPx = gcrLiveStopPx;
+ gauntletCrownRunwayBeginFinishStopPlay();
+ return;
+ }
+ gauntletCrownRunwayBgScrollPx += gcrInc;
+ }
+
+ /** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */
+ const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
+ const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
+ let stackTowerScrollBgIntroImg = null;
+ let stackTowerScrollBgLoopImg = null;
+ let stackTowerScrollBgPx = 0;
+ let stackTowerScrollBgSpeedPxPerSec = 40;
+ let stackTowerScrollBgOff = false;
+ let stackTowerScrollBgFlowDown = false;
+ /** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */
+ let stackTowerScrollBgSrawWorldBaselinePlay = null;
+ /** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */
+ let stackTowerScrollBgLoopDrawHPlay = 0;
+ /** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */
+ let stackTowerBgScrollStepAnimPlay = null;
+ let stackTowerBgScrollPendingStepDeltaPlay = 0;
+
+ function stackTowerScrollBgMapEligible() {
+ return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID);
+ }
+
+ function stackTowerScrollBgImagesReady() {
+ if (!stackTowerScrollBgMapEligible()) return false;
+ const a = stackTowerScrollBgIntroImg;
+ const b = stackTowerScrollBgLoopImg;
+ return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
+ }
+
+ /** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */
+ function stackTowerScrollBgDrawActive() {
+ return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff;
+ }
+
+ function reloadStackTowerScrollBgFromMap() {
+ stackTowerScrollBgIntroImg = null;
+ stackTowerScrollBgLoopImg = null;
+ stackTowerScrollBgPx = 0;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgLoopDrawHPlay = 0;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ if (!stackTowerScrollBgMapEligible()) return;
+ const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {};
+ stackTowerScrollBgOff = raw.enabled !== true;
+ {
+ const spNum = Number(raw.speedPxPerSec);
+ stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum)
+ ? Math.max(0, Math.min(400, Math.floor(spNum)))
+ : 0;
+ }
+ /* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */
+ const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
+ const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
+ stackTowerScrollBgFlowDown = !rawIsDownKeyword;
+ if (stackTowerScrollBgOff) return;
+ const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO;
+ const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP;
+ let pending = 2;
+ const bump = () => {
+ pending--;
+ if (pending <= 0) {
+ const intro0 = stackTowerScrollBgIntroImg;
+ const loop0 = stackTowerScrollBgLoopImg;
+ if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) {
+ const cwR0 = Math.max(1, Math.round(canvas.width));
+ stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth)));
+ }
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+ };
+ const im1 = new Image();
+ im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); };
+ im1.onerror = () => { bump(); };
+ im1.src = introSrc;
+ const im2 = new Image();
+ im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); };
+ im2.onerror = () => { bump(); };
+ im2.src = loopSrc;
+ }
+
+ function stackTowerScrollBgSyncInitialScrollToBottom() {
+ const intro = stackTowerScrollBgIntroImg;
+ if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const chR = Math.max(1, Math.round(canvas.height));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ if (stackTowerScrollBgFlowDown) {
+ stackTowerScrollBgPx = 0;
+ } else {
+ /* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */
+ stackTowerScrollBgPx = drawHIntro - chR;
+ }
+ }
+
+ /**
+ * พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop
+ * เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ
+ */
+ function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) {
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(S) || 0;
+ if (s < dI) return s;
+ return s - Math.floor((s - dI) / dH) * dH;
+ }
+
+ /**
+ * offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0)
+ * เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด)
+ */
+ function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) {
+ if (Sraw0 == null) return 0;
+ const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
+ const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
+ const s = Number(Sraw) || 0;
+ const s0 = Number(Sraw0) || 0;
+ const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH);
+ const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH);
+ return fs - fs0;
+ }
+
+ /** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */
+ function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) {
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!intro || !intro.complete || !loop || !loop.complete) return;
+ const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleI = cwR / intro.naturalWidth;
+ const drawHIntro = Math.round(intro.naturalHeight * scaleI);
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0;
+ const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0;
+ const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
+ const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop);
+ const vp0 = S;
+ const vp1 = S + chR;
+ const yLimit = vp1 + drawHLoop * 96;
+ const flowDown = stackTowerScrollBgFlowDown;
+
+ function stripTopToDestY(stripTop, drawH) {
+ if (!flowDown) return stripTop - S;
+ return S + chR - (stripTop + drawH);
+ }
+
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ ctx.beginPath();
+ ctx.rect(0, 0, cwR, chR);
+ ctx.clip();
+
+ if (vp0 < 0) {
+ let k = 1;
+ if (vp0 < -drawHLoop * 4) {
+ k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
+ }
+ for (; k < 50000; k++) {
+ const y0 = -k * drawHLoop;
+ if (y0 >= vp1 + drawHLoop) break;
+ if (y0 + drawHLoop <= vp0) continue;
+ const dy = Math.round(stripTopToDestY(y0, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ }
+ }
+
+ if (drawHIntro > vp0 && vp1 > 0) {
+ const dy = Math.round(stripTopToDestY(0, drawHIntro));
+ ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
+ }
+
+ let y = drawHIntro;
+ if (y < yLimit && vp1 > drawHIntro) {
+ if (vp0 > drawHIntro) {
+ const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
+ /* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */
+ y = drawHIntro + n * drawHLoop;
+ }
+ while (y < yLimit) {
+ const dy = Math.round(stripTopToDestY(y, drawHLoop));
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
+ y += drawHLoop;
+ }
+ }
+ ctx.restore();
+ }
+
+ /** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */
+ function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) {
+ if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return;
+ const loop = stackTowerScrollBgLoopImg;
+ if (!loop || !loop.complete || !loop.naturalWidth) return;
+ const cwR = Math.max(1, Math.round(cw));
+ const chR = Math.max(1, Math.round(ch));
+ const scaleL = cwR / loop.naturalWidth;
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
+ const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4);
+ const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx;
+ const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw
+ + getStackTowerPost50BgScrollExtraPxPlay(chR);
+ const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop;
+ let y = -scroll;
+ ctx.save();
+ ctx.imageSmoothingEnabled = false;
+ while (y < skyScreenH + drawHLoop) {
+ if (y + drawHLoop > 0) {
+ ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop);
+ }
+ y += drawHLoop;
+ }
+ ctx.restore();
+ }
+
+ function tickStackTowerBgScrollStepAnimPlay() {
+ const q = stackTowerBgScrollStepAnimPlay;
+ if (!q) return;
+ const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur));
+ const e = 1 - Math.pow(1 - t, 3);
+ stackTowerScrollBgPx = q.from + (q.to - q.from) * e;
+ if (t >= 1) {
+ stackTowerScrollBgPx = q.to;
+ stackTowerBgScrollStepAnimPlay = null;
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ if (stackTowerBgScrollPendingStepDeltaPlay !== 0) {
+ const d = stackTowerBgScrollPendingStepDeltaPlay;
+ stackTowerBgScrollPendingStepDeltaPlay = 0;
+ startStackTowerBgScrollStepAnimPlay(d);
+ }
+ }
+ }
+
+ function getStackTowerBgScrollStepEveryLayersPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const n = Math.floor(Number(raw.stepEveryLayers));
+ return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0;
+ }
+
+ function getStackTowerBgScrollStepAnimMsPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 520;
+ const t = Math.floor(Number(raw.stepAnimMs));
+ return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520;
+ }
+
+ function getStackTowerBgScrollStepScrollPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!raw || typeof raw !== 'object') return 0;
+ const d = Math.floor(Number(raw.stepScrollPx));
+ return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0;
+ }
+
+ function startStackTowerBgScrollStepAnimPlay(deltaPx) {
+ if (!stackTowerScrollBgDrawActive() || !deltaPx) return;
+ if (stackTowerBgScrollStepAnimPlay) {
+ stackTowerBgScrollPendingStepDeltaPlay += deltaPx;
+ return;
+ }
+ const dur = getStackTowerBgScrollStepAnimMsPlay();
+ stackTowerBgScrollStepAnimPlay = {
+ from: stackTowerScrollBgPx,
+ to: stackTowerScrollBgPx + deltaPx,
+ t0: performance.now(),
+ dur,
+ };
+ }
+
+ function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) {
+ if (!stackTowerScrollBgDrawActive()) return;
+ const every = getStackTowerBgScrollStepEveryLayersPlay();
+ if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return;
+ let delta = getStackTowerBgScrollStepScrollPxPlay();
+ if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200);
+ delta = Math.min(8000, Math.max(8, delta));
+ startStackTowerBgScrollStepAnimPlay(delta);
+ }
+
+ function getStackTowerStripReleaseGapWorldPxPlay() {
+ const raw = mapData && mapData.stackTowerBgScroll;
+ if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null;
+ const g = Number(raw.releaseGapWorldPx);
+ if (!Number.isFinite(g) || g < 0) return null;
+ return Math.min(800, g);
+ }
+
+ function updateStackTowerSwingYForStripGapPlay() {
+ if (!stackMini || !isStackTowerMissionUiMapPlay()) return;
+ const gap = getStackTowerStripReleaseGapWorldPxPlay();
+ if (gap == null) return;
+ const m = stackMini;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const nLay = m.layers ? m.layers.length : 0;
+ const topBlockTopY = m.floorWorldY - nLay * lh;
+ const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh);
+ m.swingWorldY = topBlockTopY - gap + swingLift;
+ }
+
+ function tickStackTowerScrollBg(dtSec) {
+ if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return;
+ if (!stackTowerScrollBgImagesReady()) return;
+ tickStackTowerBgScrollStepAnimPlay();
+ if (stackTowerBgScrollStepAnimPlay) return;
+ const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec;
+ if (spd <= 0) return;
+ /* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */
+ stackTowerScrollBgPx += spd;
+ }
+
+ /**
+ * ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50)
+ * แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง)
+ */
+ function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const zPn = Number(zPan) > 0 ? zPan : zoom;
+ const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720));
+ const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn;
+ const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx);
+ const intro = stackTowerScrollBgIntroImg;
+ const loop = stackTowerScrollBgLoopImg;
+ let totalScreen = boostScreen + post50Screen;
+ if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth
+ && loop && loop.complete && loop.naturalWidth && canvas && canvas.width) {
+ const cwR = Math.max(1, Math.round(canvas.width));
+ const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
+ const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth)));
+ const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen;
+ if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw;
+ const S0 = stackTowerScrollBgSrawWorldBaselinePlay;
+ let stripY;
+ if (stackTowerScrollBgFlowDown) {
+ const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop);
+ stripY = (boostScreen + post50Screen) * 2 - Sview;
+ } else {
+ stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0);
+ }
+ totalScreen = stripY;
+ } else if (stackTowerScrollBgDrawActive()) {
+ const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
+ totalScreen = px + boostScreen + post50Screen;
+ }
+ return totalScreen;
+ }
+
+ /** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
+ let mapGridImageImgs = [];
+ let mapGridImageHeldImgs = [];
+ let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 };
+ const others = new Map();
+ /** ===== MG3 terminal log (เกมจริง) — hacking-terminal ตามสเปก #mg3-terminal-log ===== */
+ let mg3TermLog = [];
+ let mg3TermLineNo = 0;
+ const MG3_TERM_MAX = 240;
+ function mg3TermPush(lines) {
+ const arr = Array.isArray(lines) ? lines : [lines];
+ for (let i = 0; i < arr.length; i++) mg3TermLog.push(String(arr[i] == null ? '' : arr[i]));
+ while (mg3TermLog.length > MG3_TERM_MAX) mg3TermLog.shift();
+ }
+ function mg3TermReset() { mg3TermLog = []; mg3TermLineNo = 0; }
+ function mg3TermBoot() {
+ mg3TermLog = []; mg3TermLineNo = 0;
+ mg3TermPush([
+ '[INIT] Booting decryption sequence...',
+ '[SYS] Locating target server...',
+ '[SYS] Connection established. Status: SECURE.',
+ '[HACK] Bypassing "Vault Lockdown" protocol...',
+ '[HACK] Disabling Cyber Security Check...',
+ '[LOAD] Injecting Decryption_Core.exe...',
+ '[READY] Awaiting manual code block alignment...',
+ '>>> STACKING SEQUENCE START <<<',
+ ]);
+ }
+ function mg3TermOnPlace(perfect, progressPct) {
+ mg3TermLineNo += 1;
+ const n = mg3TermLineNo;
+ if (n >= 100 || (Number.isFinite(Number(progressPct)) && Number(progressPct) >= 99.5)) {
+ mg3TermPush([
+ '>>> Compile Line [100]: SUCCESS. Maximum stack reached.',
+ '[DATA]: Decryption progress: 100%. Master key generated.',
+ '[HACK]: "Vault Lockdown" bypassed. Firewall disabled.',
+ '[SYS]: Evidence files unlocked. Initiating secure download...',
+ '>>> MISSION ACCOMPLISHED. Disconnecting safely. <<<',
+ ]);
+ } else if (perfect) {
+ mg3TermPush('>>> Compile Line [' + n + ']: PERFECT. Synergy bonus!');
+ } else {
+ mg3TermPush('>>> Compile Line [' + n + ']: SUCCESS. Stack stable.');
+ }
+ }
+ function mg3TermOnMiss(attemptsRemaining) {
+ if (attemptsRemaining <= 0) {
+ mg3TermPush([
+ '[CRITICAL]: Packet loss detected. Compile FAILED. (Attempts remaining: 0)',
+ '[ALERT]: Cyber Security Check triggered! Intruder traced.',
+ '[SYS]: Initiating "Vault Lockdown" protocol...',
+ '>>> FATAL ERROR: Connection Terminated. SYSTEM LOCKED. <<<',
+ ]);
+ } else {
+ mg3TermPush('[WARNING]: Packet loss detected. Compile FAILED. (Attempts remaining: ' + attemptsRemaining + '/3)');
+ }
+ }
+
+ /** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
+ const STACK_PREVIEW_HUD_LOG_MAX = 8;
+ let stackPreviewHudLog = [];
+ function stackPreviewPushHudLog(line) {
+ if (!playBotsEnabled()) return;
+ stackPreviewHudLog.push(String(line || '').slice(0, 96));
+ while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
+ }
+ function stackPreviewLogStackDrop(hit, actor) {
+ if (!playBotsEnabled() || !hit) return;
+ const bid = actor && actor.botId;
+ let nm = 'NODE';
+ if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
+ if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`);
+ else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`);
+ else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`);
+ }
+ const keys = {};
+ const characterImages = {};
+ /** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */
+ const cyberHudScoreAvatarUrlCache = new Map();
+ const CYBER_HUD_AV_URL_CACHE_CAP = 56;
+ function createDefaultAvatarImg() {
+ const c = document.createElement('canvas');
+ c.width = 64; c.height = 64;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = '#7aa2f7';
+ ctx.beginPath();
+ ctx.arc(32, 22, 14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(32, 48, 18, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = '#1a1b26';
+ ctx.beginPath();
+ ctx.arc(28, 20, 3, 0, Math.PI * 2);
+ ctx.arc(36, 20, 3, 0, Math.PI * 2);
+ ctx.fill();
+ const img = new Image();
+ img.src = c.toDataURL('image/png');
+ return img;
+ }
+ const defaultAvatarImg = createDefaultAvatarImg();
+ const characterAnimations = {};
+ const characterIdleAnimations = {};
+ const CHARACTER_ANIM_FRAMES = 4;
+ const CHARACTER_ANIM_FRAME_MS = 200;
+
+ /** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
+ /** true เมื่ออยู่ในเกมวิ่งหลบ (gauntlet runner) ช่วงเล่นจริง — ใช้เร่งจังหวะขาให้ดูเหมือน "วิ่ง" */
+ function gauntletRunnerAnimActivePlay() {
+ try {
+ return isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ } catch (e) { return false; }
+ }
+
+ function walkAnimPhaseIndex(now, isWalking) {
+ if (!isWalking) return 0;
+ const t = (typeof now === 'number' ? now : Date.now());
+ /* gauntlet วิ่งหลบ: ย่นเวลาเฟรม ~1.85× ให้ขาสลับเร็ว = วิ่ง (ไม่ใช่เดิน) */
+ const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.54 : CHARACTER_ANIM_FRAME_MS;
+ return Math.floor(t / frameMs) % CHARACTER_ANIM_FRAMES;
+ }
+
+ /** เลือกเฟรมสูงสุดที่โหลดแล้วและ <= phase (เดิมถอย) */
+ function pickLoadedWalkFrameIndex(anim, phase) {
+ if (!anim || !anim.frames || !anim.frames.length) return -1;
+ const maxK = Math.min(phase, anim.frames.length - 1, CHARACTER_ANIM_FRAMES - 1);
+ for (let k = maxK; k >= 0; k--) {
+ const f = anim.frames[k];
+ if (f && f.complete && f.naturalWidth) return k;
+ }
+ return -1;
+ }
+
+ /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */
+ function isUploadedCharAssetId(id) {
+ if (!id) return false;
+ const s = String(id).trim();
+ return /^char-\d/i.test(s);
+ }
+
+ function useMultiFrameIdleSpriteSheets(id) {
+ if (!id) return true;
+ return !isUploadedCharAssetId(id);
+ }
+
+ function ensureCharacterIdleAnim(id, dir) {
+ const d = dir || 'down';
+ /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */
+ if (!useMultiFrameIdleSpriteSheets(id)) {
+ const key = String(id) + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+ const key = id + '_' + d + '_idle';
+ let anim = characterIdleAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterIdleAnimations[key] = anim;
+ const enc = encodeURIComponent(id);
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + enc + '_' + d + '_idle_' + i + '.png';
+ anim.frames.push(img);
+ }
+ const fb = new Image();
+ fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png';
+ anim.fallback = fb;
+ }
+ return anim;
+ }
+
+ /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */
+ function idleAnimPhaseIndex(now) {
+ const t = typeof now === 'number' ? now : Date.now();
+ return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES;
+ }
+
+ function characterIdleSpritesVisible(id, dir) {
+ if (!id) return false;
+ const anim = ensureCharacterIdleAnim(id, dir || 'down');
+ if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return true;
+ for (let i = 0; i < anim.frames.length; i++) {
+ const f = anim.frames[i];
+ if (f && f.complete && f.naturalWidth) return true;
+ }
+ return false;
+ }
+
+ /** สุ่มสีตามเลเยอร์เดียวกับ character.html: bodyColor / hairColor / headColor (ลำดับซ้อนเหมือน composeLayeredFrame) */
+ const PLAY_LAYER_ORDER = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
+ const PLAY_LAYER_TINT_KEY = { bodyColor: 'body', headColor: 'head', hairColor: 'hair' };
+ const PLAY_TINT_HEAD = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ const PLAY_TINT_HAIR = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
+ const playLayerMode = {};
+ /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
+ const playLayerAllDirsQueued = {};
+ /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */
+ let playCharLayerApi = { status: 'idle', map: null };
+ /** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */
+ const playCharDiskLayersById = Object.create(null);
+
+ function ensurePlayCharLayerListFetch() {
+ if (playCharLayerApi.status !== 'idle') return;
+ playCharLayerApi.status = 'loading';
+ fetch(BASE + '/api/characters')
+ .then((r) => r.json())
+ .then((list) => {
+ const map = Object.create(null);
+ playCharacterIdRoster = [];
+ if (Array.isArray(list)) {
+ list.forEach((c) => {
+ if (!c || !c.id) return;
+ playCharacterIdRoster.push(String(c.id));
+ map[c.id] = !!c.hasLayerFiles;
+ if (Array.isArray(c.layers) && c.layers.length) {
+ const st = new Set();
+ c.layers.forEach((n) => {
+ if (typeof n === 'string' && n.length) st.add(n);
+ });
+ if (st.size) playCharDiskLayersById[c.id] = st;
+ else delete playCharDiskLayersById[c.id];
+ } else {
+ delete playCharDiskLayersById[c.id];
+ }
+ });
+ }
+ playCharLayerApi = { status: 'done', map };
+ })
+ .catch(() => {
+ playCharacterIdRoster = [];
+ playCharLayerApi = { status: 'done', map: Object.create(null) };
+ });
+ }
+
+ /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */
+ function playCharLayerFromApi(characterId) {
+ if (playCharLayerApi.status !== 'done' || !characterId) return null;
+ if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) {
+ return playCharLayerApi.map[characterId];
+ }
+ return null;
+ }
+
+ /** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */
+ function playCharDiskLayersSet(characterId) {
+ if (!characterId || playCharLayerApi.status !== 'done') return null;
+ const s = playCharDiskLayersById[characterId];
+ return s && s.size ? s : null;
+ }
+
+ const playLayerImageCache = new Map();
+ const playLayerCompositeCache = new Map();
+ function pickRandomPlayTint() {
+ return {
+ head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)],
+ hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)],
+ body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)],
+ };
+ }
+
+ /** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */
+ function hashStringFnv1a32(str) {
+ let h = 2166136261 >>> 0;
+ const s = String(str || '');
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+ }
+
+ function playTintFromPeerId(id) {
+ const s = String(id || 'x');
+ const h0 = hashStringFnv1a32(s);
+ const h1 = hashStringFnv1a32(s + '\n1hair');
+ const h2 = hashStringFnv1a32(s + '\n2body');
+ return {
+ head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length],
+ hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length],
+ body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length],
+ };
+ }
+
+ const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
+ /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */
+ const PLAY_LOBBY_THEME_HEX = [
+ '#d72520', '#ef8508', '#efe237', '#5bb443',
+ '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe',
+ ];
+ const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1'];
+ let playLobbyBotThemes = [];
+
+ function parsePlayLobbyThemeIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 8) ? v : null;
+ }
+
+ function parsePlayLobbySkinIndex(n) {
+ const v = Math.floor(Number(n));
+ return (v >= 1 && v <= 3) ? v : null;
+ }
+
+ function playTintFromLobbyThemeIndices(themeIndex, skinIndex) {
+ const ti = parsePlayLobbyThemeIndex(themeIndex);
+ const si = parsePlayLobbySkinIndex(skinIndex);
+ const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : '';
+ const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : '';
+ if (!bodyHex && !headHex) return null;
+ return playTintFromLobbyRgb(bodyHex || null, headHex || null);
+ }
+
+ function playTintFromJoinPeer(peerRow) {
+ if (!peerRow) return null;
+ return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex);
+ }
+
+ function playTintForPreviewBotId(peerId) {
+ if (!isPreviewBotId(peerId)) return null;
+ const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10);
+ if (!Number.isFinite(idx) || idx < 0) return null;
+ const slot = playLobbyBotThemes[idx];
+ if (!slot) return null;
+ const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex);
+ if (!tint) return null;
+ try {
+ const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}');
+ map['__lobby_bot_' + idx] = {
+ colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '',
+ colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '',
+ };
+ sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map));
+ } catch (e) { /* ignore */ }
+ return tint;
+ }
+
+ function lobbyBotTintsStorageKeyPlay() {
+ const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default');
+ return 'justiceLobbyBotTints:' + sp;
+ }
+
+ function rgbCssToHexPlay(rgb) {
+ if (!rgb) return '';
+ const s = String(rgb).trim();
+ if (s.charAt(0) === '#') return s;
+ const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
+ if (!m) return '';
+ return '#' + [m[1], m[2], m[3]].map((n) => {
+ const v = Math.max(0, Math.min(255, parseInt(n, 10)));
+ return v.toString(16).padStart(2, '0');
+ }).join('');
+ }
+
+ function playTintFromLobbyRgb(themeRgb, skinRgb) {
+ const bodyHex = rgbCssToHexPlay(themeRgb);
+ const headHex = rgbCssToHexPlay(skinRgb);
+ if (!headHex && !bodyHex) return null;
+ return {
+ head: headHex || '#fbd5c4',
+ hair: bodyHex || '#fb4941',
+ body: bodyHex || '#fb4941',
+ };
+ }
+
+ function loadPlayTintFromLobbyPlayerStorage() {
+ try {
+ const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
+ if (raw) {
+ const j = JSON.parse(raw);
+ if (j && typeof j === 'object') {
+ if (j.head && j.body) {
+ return {
+ head: String(j.head),
+ hair: String(j.hair || j.body),
+ body: String(j.body),
+ };
+ }
+ const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb);
+ if (fromJson) return fromJson;
+ }
+ }
+ } catch (e) { /* ignore */ }
+ try {
+ const theme = localStorage.getItem('lobbyThemeColor') || '';
+ const skin = localStorage.getItem('lobbySkinTone') || '';
+ return playTintFromLobbyRgb(theme, skin);
+ } catch (e2) {
+ return null;
+ }
+ }
+
+ function loadPlayTintForBotPeerId(peerId) {
+ const sid = String(peerId || '');
+ let botKey = sid;
+ if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) {
+ botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length);
+ }
+ try {
+ const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay());
+ if (!raw) return null;
+ const map = JSON.parse(raw);
+ const saved = map && map[botKey];
+ if (!saved) return null;
+ return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */
+ function resolvePlayTintForPeer(peerId, peerRow) {
+ const sid = String(peerId || '');
+ const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null;
+ if (fromRow) return fromRow;
+ const previewTint = playTintForPreviewBotId(sid);
+ if (previewTint) return previewTint;
+ if (myId != null && sid === String(myId)) {
+ const meTint = loadPlayTintFromLobbyPlayerStorage();
+ if (meTint) return meTint;
+ }
+ if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint;
+ const botTint = loadPlayTintForBotPeerId(sid);
+ if (botTint) return botTint;
+ return playTintFromPeerId(sid);
+ }
+
+ function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) {
+ const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
+ if (!tint) return;
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ me.playTint = tint;
+ return;
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o) o.playTint = tint;
+ if (isPreviewBotId(sid)) playTintForPreviewBotId(sid);
+ }
+
+ function applyPlayLobbyTintSync(data) {
+ if (!data || typeof data !== 'object') return;
+ if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes;
+ if (Array.isArray(data.peers)) {
+ data.peers.forEach(function (p) {
+ if (!p || p.id == null) return;
+ applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex);
+ });
+ }
+ if (playBotsEnabled()) rebalancePreviewBots();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function getPlayTintForMissionAvatar(peerId) {
+ const sid = peerId != null ? String(peerId) : '';
+ if (myId != null && sid === String(myId)) {
+ return me.playTint || resolvePlayTintForPeer(sid);
+ }
+ const o = others.get(peerId) || others.get(sid);
+ if (o && o.playTint) return o.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */
+ function missionEnsureHumanInTopRanked(rows, maxN) {
+ const cap = Math.max(1, maxN || 5);
+ if (!Array.isArray(rows) || !rows.length) return [];
+ const humanId = myId != null ? String(myId) : null;
+ const humanRow = humanId
+ ? rows.find((r) => r && r.id != null && String(r.id) === humanId)
+ : rows.find((r) => r && r.isLocalHuman);
+ let top = rows.slice(0, cap);
+ if (!humanRow) return top;
+ const hid = humanRow.id != null ? String(humanRow.id) : '';
+ if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top;
+ if (top.length >= cap) top = top.slice(0, cap - 1);
+ top.push(humanRow);
+ return top;
+ }
+
+ function hexToRgb01(hex) {
+ const h = (hex || '').replace('#', '').trim();
+ if (h.length !== 6) return [1, 1, 1];
+ return [
+ parseInt(h.slice(0, 2), 16) / 255,
+ parseInt(h.slice(2, 4), 16) / 255,
+ parseInt(h.slice(4, 6), 16) / 255,
+ ];
+ }
+
+ function tintPlayLayerImageData(imageData, tintHex) {
+ const rgb = hexToRgb01(tintHex);
+ const tr = rgb[0], tg = rgb[1], tb = rgb[2];
+ const d = imageData.data;
+ for (let i = 0; i < d.length; i += 4) {
+ if (d[i + 3] < 12) continue;
+ const r = d[i], g = d[i + 1], b = d[i + 2];
+ const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ d[i] = Math.min(255, Math.round(tr * 255 * L));
+ d[i + 1] = Math.min(255, Math.round(tg * 255 * L));
+ d[i + 2] = Math.min(255, Math.round(tb * 255 * L));
+ }
+ }
+
+ function drawPlayTintedLayer(ctx, w, h, img, tintHex) {
+ if (!tintHex) {
+ ctx.drawImage(img, 0, 0, w, h);
+ return;
+ }
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const x = c.getContext('2d');
+ x.drawImage(img, 0, 0, w, h);
+ try {
+ const idata = x.getImageData(0, 0, w, h);
+ tintPlayLayerImageData(idata, tintHex);
+ x.putImageData(idata, 0, 0);
+ } catch (e) {
+ x.clearRect(0, 0, w, h);
+ x.drawImage(img, 0, 0, w, h);
+ }
+ ctx.drawImage(c, 0, 0, w, h);
+ }
+
+ function ensurePlayLayerImage(url) {
+ let img = playLayerImageCache.get(url);
+ if (!img) {
+ img = new Image();
+ img.src = url;
+ playLayerImageCache.set(url, img);
+ }
+ return img;
+ }
+
+ /** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม
+ * ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */
+ const _playPreloadedChars = new Set();
+ const _playPreloadedTintKeys = new Set();
+ function preloadPlayCharacterLayers(charId, _tries) {
+ if (!charId || _playPreloadedChars.has(charId)) return;
+ try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ }
+ try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ }
+ const diskSet = playCharDiskLayersSet(charId);
+ if (!diskSet) {
+ // manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404)
+ const t = _tries || 0;
+ if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450);
+ return;
+ }
+ _playPreloadedChars.add(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); };
+ dirs.forEach(function (dir) {
+ PLAY_LAYER_ORDER.forEach(function (ln) {
+ if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์
+ if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face
+ for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) {
+ warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi));
+ }
+ warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0));
+ });
+ });
+ }
+
+ /** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */
+ function preloadPlayTintForAvatar(charId, tint) {
+ if (!charId || !tint || typeof tint.head !== 'string') return;
+ const key = [charId, tint.head, tint.hair, tint.body].join('|');
+ if (_playPreloadedTintKeys.has(key)) return;
+ _playPreloadedTintKeys.add(key);
+ preloadPlayCharacterLayers(charId);
+ const dirs = ['down', 'up', 'left', 'right'];
+ const now = Date.now();
+ dirs.forEach(function (dir) {
+ const rawWalk = getCharacterFrame(charId, dir, now, true);
+ const rawIdle = getCharacterFrame(charId, dir, now, false);
+ if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint);
+ if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint);
+ });
+ }
+
+ function preloadPlayOccupantsTints() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) {
+ preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))));
+ }
+ others.forEach(function (o, id) {
+ if (!o || !o.characterId) return;
+ preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id));
+ });
+ }
+
+ /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */
+ function layerUrlCandidates(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir;
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ add(base + '_layer_' + layerName + '.png');
+ return out;
+ }
+
+ /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */
+ function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
+ const enc = encodeURIComponent(id);
+ const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
+ const out = [];
+ function add(u) {
+ if (out.indexOf(u) === -1) out.push(u);
+ }
+ // idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว
+ add(base + '_layer_' + layerName + '.png');
+ add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
+ if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
+ return out;
+ }
+
+ function shadowUrlCandidates(id, dir, frameIndex) {
+ const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ function shadowUrlCandidatesIdle(id, dir, frameIndex) {
+ const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
+ const def = BASE + '/img/default-shadow-' + dir + '.png';
+ if (c.indexOf(def) === -1) c.push(def);
+ return c;
+ }
+
+ /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
+ function resolvePlayLayerImage(urls) {
+ for (let i = 0; i < urls.length; i++) {
+ const img = ensurePlayLayerImage(urls[i]);
+ if (!img.complete) return { status: 'pending' };
+ if (img.naturalWidth > 0) return { status: 'ok', img };
+ }
+ return { status: 'missing' };
+ }
+
+ function ensurePlayLayerProbesAllDirections(characterId) {
+ if (!characterId || playLayerAllDirsQueued[characterId]) return;
+ playLayerAllDirsQueued[characterId] = true;
+ ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
+ }
+
+ function playCharAnyDirectionLayered(characterId) {
+ if (!characterId) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
+ }
+
+ /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
+ function playCharLayerDiscoveryPending(characterId) {
+ if (!characterId) return true;
+ const api = playCharLayerFromApi(characterId);
+ if (api === true || api === false) return false;
+ if (playCharAnyDirectionLayered(characterId)) return false;
+ return ['up', 'down', 'left', 'right'].some((d) => {
+ const m = playLayerMode[characterId + '|' + d];
+ return m === undefined || m === 'pending';
+ });
+ }
+
+ function ensurePlayLayerProbe(characterId, dir) {
+ const key = characterId + '|' + dir;
+ if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
+ if (playLayerMode[key] === 'pending') return;
+ playLayerMode[key] = 'pending';
+ const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
+ let ci = 0;
+ function tryNextProbe() {
+ if (playLayerMode[key] !== 'pending') return;
+ if (ci >= cands.length) {
+ playLayerMode[key] = 'none';
+ return;
+ }
+ const url = cands[ci++];
+ const img = ensurePlayLayerImage(url);
+ const fin = () => {
+ if (playLayerMode[key] !== 'pending') return;
+ if (img.naturalWidth > 0) {
+ playLayerMode[key] = 'layered';
+ return;
+ }
+ tryNextProbe();
+ };
+ img.onload = fin;
+ img.onerror = fin;
+ if (img.complete) fin();
+ }
+ tryNextProbe();
+ }
+
+ function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
+ if (!id) return 0;
+ const key = id + '_' + dir;
+ const anim = characterAnimations[key];
+ if (!anim) return 0;
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ return fi >= 0 ? fi : 0;
+ }
+
+ function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
+ const useIdle = opts && opts.idle;
+ if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
+ const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ const cctx = c.getContext('2d');
+ let anyTintColorLayer = false;
+ let skippedShadowWhilePending = false;
+ const diskLayers = playCharDiskLayersSet(id);
+ /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */
+ const idleLayerStripes = !!useIdle;
+ const resDir = dir;
+ const resFi = frameIndex;
+ for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
+ const layerName = PLAY_LAYER_ORDER[li];
+ if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
+ /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */
+ if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') {
+ continue;
+ }
+ let urls;
+ if (layerName === 'shadow') {
+ urls = idleLayerStripes
+ ? shadowUrlCandidatesIdle(id, dir, frameIndex)
+ : shadowUrlCandidates(id, resDir, resFi);
+ } else {
+ urls = idleLayerStripes
+ ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex)
+ : layerUrlCandidates(id, resDir, layerName, resFi);
+ }
+ const r = resolvePlayLayerImage(urls);
+ /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
+ if (r.status === 'pending' && layerName === 'shadow') {
+ skippedShadowWhilePending = true;
+ continue;
+ }
+ if (r.status === 'pending') return null;
+ if (r.status === 'missing') continue;
+ const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
+ if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
+ drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
+ }
+ /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
+ if (!anyTintColorLayer) return null;
+ return { canvas: c, skipCache: skippedShadowWhilePending };
+ }
+
+ function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
+ if (!tint || !characterId || !rawImg) return rawImg;
+ ensurePlayCharLayerListFetch();
+ ensurePlayLayerProbesAllDirections(characterId);
+ const dirn = dir || 'down';
+ const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
+ const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
+
+ if (playCharLayerDiscoveryPending(characterId)) return rawImg;
+
+ const apiFlag = playCharLayerFromApi(characterId);
+ /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
+ const charHasLayerFiles = apiFlag === true
+ || (apiFlag == null && playCharAnyDirectionLayered(characterId));
+
+ /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */
+ if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) {
+ return rawImg;
+ }
+ const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
+
+ if (charHasLayerFiles) {
+ if (!isWalking) {
+ const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase;
+ const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
+ const hitI = playLayerCompositeCache.get(cacheKeyIdle);
+ if (hitI) return hitI;
+ const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true });
+ if (packI && packI.canvas) {
+ if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
+ return packI.canvas;
+ }
+ }
+ const hit = playLayerCompositeCache.get(cacheKeyWalk);
+ if (hit) return hit;
+ const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
+ if (pack && pack.canvas) {
+ if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
+ return pack.canvas;
+ }
+ return rawImg;
+ }
+
+ /*
+ * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม)
+ */
+ return rawImg;
+ }
+
+ function getCharacterImg(id, direction) {
+ if (!id) return null;
+ const key = id + '_' + (direction || 'down');
+ if (characterImages[key]) return characterImages[key];
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
+ characterImages[key] = img;
+ return img;
+ }
+
+ function getCharacterFrame(id, direction, now, isWalking) {
+ if (!id) return null;
+ const dir = direction || 'down';
+ const t = typeof now === 'number' ? now : Date.now();
+ if (!isWalking) {
+ const idleAnim = ensureCharacterIdleAnim(id, dir);
+ let idlePhase = idleAnimPhaseIndex(t);
+ if (isUploadedCharAssetId(id)) idlePhase = 0;
+ const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
+ if (idleFi >= 0) return idleAnim.frames[idleFi];
+ const idfb = idleAnim.fallback;
+ if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
+ }
+ const key = id + '_' + dir;
+ let anim = characterAnimations[key];
+ if (!anim) {
+ anim = { frames: [], fallback: null };
+ characterAnimations[key] = anim;
+ for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
+ const img = new Image();
+ img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
+ anim.frames.push(img);
+ }
+ anim.fallback = getCharacterImg(id, dir);
+ }
+ const phase = walkAnimPhaseIndex(now, isWalking);
+ const fi = pickLoadedWalkFrameIndex(anim, phase);
+ if (fi >= 0) return anim.frames[fi];
+ const fb = anim.fallback;
+ if (fb && fb.complete && fb.naturalWidth) return fb;
+ return null;
+ }
+
+ function getAvatarImg(characterId, direction, now, isWalking) {
+ const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
+ if (img) return img;
+ return defaultAvatarImg;
+ }
+
+ /** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */
+ const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:';
+ const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:';
+ const playLobbyIdleAvatarCache = new Map();
+
+ function playLobbyIdleAvatarSrcForCharacter(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return '';
+ try {
+ const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || '';
+ if (ss && ss.indexOf('data:image/') === 0) return ss;
+ const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || '';
+ if (ls && ls.indexOf('data:image/') === 0) return ls;
+ } catch (e) { /* ignore */ }
+ return '';
+ }
+
+ function ensurePlayLobbyIdleAvatarImage(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return;
+ if (playLobbyIdleAvatarCache.get(id + '_loading')) return;
+ const src = playLobbyIdleAvatarSrcForCharacter(id);
+ if (!src) return;
+ const img = new Image();
+ img.onload = function () {
+ playLobbyIdleAvatarCache.set(id, img);
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.onerror = function () {
+ playLobbyIdleAvatarCache.delete(id + '_loading');
+ };
+ img.src = src;
+ playLobbyIdleAvatarCache.set(id + '_loading', img);
+ }
+
+ function getPlayLobbyIdleAvatarImageSync(characterId) {
+ const id = String(characterId || '').trim();
+ if (!id) return null;
+ const hit = playLobbyIdleAvatarCache.get(id);
+ if (hit && hit.complete && hit.naturalWidth) return hit;
+ ensurePlayLobbyIdleAvatarImage(id);
+ return null;
+ }
+
+ /** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง ตอนยืน); ตอนเดินใช้ strip เดิน down_0..3 */
+ function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) {
+ const dir = direction || 'down';
+ const walk = !!isWalking;
+ if (dir === 'down' && useLobbyIdleComposite && !walk) {
+ const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId);
+ if (lobbyImg) return lobbyImg;
+ }
+ const rawImg = getAvatarImg(characterId, dir, now, walk);
+ if (!playTint || !characterId) return rawImg;
+ return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg;
+ }
+
+ function preloadPlayLobbyIdleAvatarsForOccupants() {
+ const meCid = me.characterId || getPlayCharacterId();
+ if (meCid) ensurePlayLobbyIdleAvatarImage(meCid);
+ others.forEach(function (o) {
+ if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId);
+ });
+ }
+
+ function playHideTopChrome() {
+ try {
+ return document.documentElement.classList.contains('play-hide-top-chrome');
+ } catch (e) {
+ return true;
+ }
+ }
+
+ function getStoredCharacterId() {
+ try {
+ const v = localStorage.getItem('gameCharacterId');
+ if (v) return v;
+ } catch (e) { /* ignore */ }
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ function getPlayCharacterId() {
+ if (forceDefaultCharacter) {
+ if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
+ return firstCharacterDefaultResolved || '';
+ }
+ return getStoredCharacterId();
+ }
+
+ /** playerKey ของผู้เล่น (ผูกบัญชีเหรียญ/คะแนน) — สร้างถ้ายังไม่มี เหมือน Main-Lobby */
+ function getJdPlayerKey() {
+ try {
+ let k = (localStorage.getItem('jdPlayerKey') || '').trim();
+ if (!k || k.length < 8) {
+ k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ localStorage.setItem('jdPlayerKey', k);
+ }
+ return k;
+ } catch (e) {
+ return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14);
+ }
+ }
+
+ /** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */
+ function pickPreviewBotCharacterId(botSlotIndex) {
+ const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0);
+ const roster = playCharacterIdRoster;
+ const humanId = String(getPlayCharacterId() || '');
+ if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID;
+ const alts = roster.filter((id) => id !== humanId);
+ const pool = alts.length ? alts : roster.slice();
+ return pool[idx % pool.length] || humanId || roster[0];
+ }
+ const MOVE_SPEED = 0.15;
+ /** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */
+ const QUIZ_CARRY_WALK_SPEED_MULT = 1.42;
+ /** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */
+ let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ const PATH_ARRIVE_THRESH = 0.15;
+
+ function clampQuizCarryWalkSpeedMultClient(n, def) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return def;
+ return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
+ }
+
+ function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) {
+ const base = QUIZ_CARRY_WALK_SPEED_MULT;
+ if (!s || typeof s !== 'object') return base;
+ const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim();
+ const mult = Number(s.carryWalkSpeedMult);
+ const mapId = (currentPlayMapId() || '').trim();
+ if (forId && mapId && forId === mapId && Number.isFinite(mult)) {
+ return clampQuizCarryWalkSpeedMultClient(mult, base);
+ }
+ return base;
+ }
+
+ function applyQuizCarryWalkSpeedFromSettingsObj(s) {
+ if (!isQuizCarry()) {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ return;
+ }
+ quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s);
+ }
+
+ function moveSpeedTilesThisFrameForWalk() {
+ return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1);
+ }
+
+ function isMovementKey(code) {
+ return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
+ }
+ function isChatFocused() {
+ return false;
+ }
+ let zoom = 1.4;
+ let froggerScore = 0;
+ let lastFroggerKey = 0;
+ let gauntletObstacles = [];
+ /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
+ let gauntletObsRenderPrev = [];
+ let gauntletObsRenderNext = [];
+ let gauntletObsBlendT0 = 0;
+ let meGauntletJumpTicks = 0;
+ /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
+ let meGauntletJumpVis = 0;
+ /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
+ let gauntletRuntimeTickMs = 220;
+ let gauntletRuntimeJumpTicks = 16;
+ /** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
+ let playStackSwingHz = 0.55;
+ /** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
+ let playStackBlockWidthTiles = null;
+ /** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */
+ let playStackTowerMissionTimeSec = 90;
+ /** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */
+ let playStackTeamMissesMax = 3;
+ /** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
+ let playStackTowerProgressBlocks = 50;
+ /** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */
+ let playStackBlockNormalUrls = ['', '', '', '', '', ''];
+ let playStackBlockHeavyUrls = ['', '', '', '', '', ''];
+ /** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */
+ let playStackHeavyBlockPercent = 35;
+ /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
+ let playJumpSurviveJumpHeightMult = 1.5;
+ /** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */
+ let playJumpSurviveMissionTimeSec = 0;
+ /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
+ let playJumpSurvivePlatformTiles = [
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ { url: '', w: 0, h: 0 },
+ ];
+ /** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */
+ let playSpaceShooterMissionTimeSec = 0;
+ /** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */
+ let playSpaceShooterShipImageUrls = ['', '', '', '', '', ''];
+ /** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */
+ let playSpaceShooterAsteroidSpriteUrls = [];
+ let playSpaceShooterAsteroidExplodeFrameMs = 70;
+ /** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
+ let playSpaceShooterAsteroidIntervalMs = 1040;
+ /** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */
+ let playSpaceShooterShipDamageOverlayUrls = ['', '', ''];
+ /** balloon_boss / Mega Virus — จาก game-timing */
+ let playBalloonBossMissionTimeSec = 0;
+ let playBalloonBossBossImageUrl = '';
+ let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
+ let playBalloonBossPlayerBalloonFallbackUrl = '';
+ /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
+ let gauntletRuntimeTimeLimitSec = 0;
+ /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
+ let gauntletEndsAtMs = null;
+ /** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */
+ let gauntletCrownRunHeldRemote = false;
+ /** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */
+ let gauntletCrownPregamePhase = null;
+ let gauntletCrownLobbyReadyMap = {};
+ let gauntletCrownCountdownTimer = null;
+ let lastGauntletJumpKey = 0;
+ /** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
+ let gauntletLaneImageUrls = [];
+ let gauntletLaserTopUrl = '';
+ let gauntletLaserBottomUrl = '';
+ let gauntletLaserLineUrl = '';
+ let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
+ let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
+ let gauntletLaserLineWidthPx = 2;
+ const gauntletAssetImageCache = new Map();
+
+ function encodeSpacesInUrlPath(t) {
+ const s = String(t || '');
+ const q = s.indexOf('?');
+ const pathPart = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ return pathPart.replace(/ /g, '%20') + query;
+ }
+
+ function decodeAssetUrlPercentRuns(t) {
+ let s = String(t || '');
+ const q = s.indexOf('?');
+ let base = q === -1 ? s : s.slice(0, q);
+ const query = q === -1 ? '' : s.slice(q);
+ for (let i = 0; i < 2; i += 1) {
+ try {
+ const d = decodeURIComponent(base);
+ if (d === base) break;
+ base = d;
+ } catch (e) {
+ break;
+ }
+ }
+ return base + query;
+ }
+
+ function normalizeGameAssetUrlForWebPlay(t) {
+ let s = String(t || '');
+ s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
+ s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
+ return s;
+ }
+
+ /** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
+ function normalizeGauntletAssetUrlForPlay(u) {
+ if (typeof u !== 'string') return '';
+ let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim()));
+ const bare0 = raw.split('?')[0].replace(/\/+$/, '');
+ if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) {
+ raw = '/Game/img/MegaVirus/Artboard%209.png';
+ }
+ if (!raw) return '';
+ if (/^https?:\/\//i.test(raw)) {
+ const z = raw.replace(/\/+$/, '');
+ return z ? encodeSpacesInUrlPath(z) : '';
+ }
+ const qIdx = raw.indexOf('?');
+ const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, '');
+ const qs = qIdx >= 0 ? raw.slice(qIdx) : '';
+ if (!base) return '';
+ if (/\.{4,}/.test(base)) return '';
+ if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs);
+ if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs);
+ return encodeSpacesInUrlPath(base + qs);
+ }
+
+ function ensureGauntletAssetImage(url) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : '');
+ if (!u) return null;
+ let rec = gauntletAssetImageCache.get(u);
+ if (rec) return rec;
+ rec = { img: new Image(), ready: false };
+ if (/^https?:\/\//i.test(u)) {
+ try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ }
+ }
+ rec.img.onload = function () { rec.ready = true; };
+ rec.img.onerror = function () { rec.ready = false; };
+ rec.img.src = u;
+ /** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */
+ if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true;
+ else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false;
+ gauntletAssetImageCache.set(u, rec);
+ return rec;
+ }
+
+ /** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */
+ function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) {
+ const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim());
+ const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim());
+ if (per) {
+ const rPer = ensureGauntletAssetImage(per);
+ const img = rPer && rPer.img;
+ if (img && img.complete && img.naturalWidth > 0) return rPer;
+ if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb);
+ return rPer;
+ }
+ if (fb) return ensureGauntletAssetImage(fb);
+ return null;
+ }
+
+ function pickGauntletLaneImageRec(obsId) {
+ if (!gauntletLaneImageUrls.length) return null;
+ const n = gauntletLaneImageUrls.length;
+ const idx = Math.abs(Number(obsId) | 0) % n;
+ const u = gauntletLaneImageUrls[idx];
+ return u ? ensureGauntletAssetImage(u) : null;
+ }
+
+ function clampClientLaserColor(s, def) {
+ const t = String(s || '').trim().slice(0, 100);
+ if (!t || /[<>"'`]/.test(t)) return def;
+ return t;
+ }
+
+ let playQuizPhaseLocal = null;
+ let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ let playQuizText = '';
+ let playQuizPhaseEndsAt = 0;
+ let playQuizTimerInterval = null;
+ /** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */
+ let playQuizQuestionIndex = 0;
+ let playQuizQuestionTotal = 0;
+ /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
+ let previewQuizPool = [];
+ /** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */
+ let previewQuizQIndex = 0;
+ let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
+ let previewQuizStep = 'read';
+ let previewQuizCurrent = null;
+ let playLiveQuizScores = {};
+ /** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */
+ let playQuizEverWrong = {};
+ /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป mng8a80o (เป็นสัญลักษณ์/บทลงโทษเชิงสนุก) */
+ const QUIZ_WRONG_GHOST_URL = BASE + '/img/QUESTION/ghost/ghost-front.png';
+ let quizWrongGhostImg = null;
+ function getQuizWrongGhostImg() {
+ if (quizWrongGhostImg) return quizWrongGhostImg;
+ const img = new Image();
+ img.decoding = 'async';
+ img.src = QUIZ_WRONG_GHOST_URL;
+ quizWrongGhostImg = img;
+ return img;
+ }
+ /** ===== โซนผี (quizGhostArea): ตอบผิด→วาร์ปมาขังในโซน เดินออกไม่ได้ — gated: ทำงานเฉพาะเมื่อแมปมีการวาดโซนผี ===== */
+ function quizGhostAreaGridPlay() {
+ const g = mapData && mapData.quizGhostArea;
+ if (!Array.isArray(g)) return null;
+ for (let y = 0; y < g.length; y++) { if (g[y] && g[y].some((v) => v === 1)) return g; }
+ return null;
+ }
+ function quizMeIsGhostPlay() {
+ return !!(isQuiz() && playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse));
+ }
+ function quizGhostConfineActivePlay() {
+ return quizMeIsGhostPlay() && !!quizGhostAreaGridPlay();
+ }
+ /** footprint ของผู้เล่นที่ (x,y) อยู่ในโซนผีครบทุกช่องไหม */
+ function quizGhostFootprintInsidePlay(x, y) {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return true;
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(','); const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+ /** วาร์ป me เข้าโซนผี (ช่องที่ footprint ลงได้ ใกล้กลางโซนสุด) — คืน true ถ้ามีโซนผี */
+ function warpMeIntoQuizGhostZonePlay() {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return false;
+ if (quizGhostFootprintInsidePlay(me.x, me.y)) return true;
+ const xs = [], ys = [];
+ for (let y = 0; y < g.length; y++) { const row = g[y] || []; for (let x = 0; x < row.length; x++) { if (row[x] === 1) { xs.push(x); ys.push(y); } } }
+ if (!xs.length) return false;
+ const cx = Math.round((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2);
+ const cy = Math.round((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2);
+ const cand = [];
+ for (let i = 0; i < xs.length; i++) cand.push([xs[i], ys[i], Math.abs(xs[i] - cx) + Math.abs(ys[i] - cy)]);
+ cand.sort((a, b) => a[2] - b[2]);
+ let tx = xs[0], ty = ys[0];
+ for (const c of cand) { if (quizGhostFootprintInsidePlay(c[0], c[1])) { tx = c[0]; ty = c[1]; break; } }
+ me.x = tx; me.y = ty; me.tx = tx; me.ty = ty;
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ return true;
+ }
+
+ /** ตรวจว่า peer/me ตอบผิดในแมป mng8a80o แล้ว → คุมผีแทน avatar */
+ function isQuizWrongGhostActiveForEnt(entId) {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ const sid = String(entId == null ? '' : entId);
+ const isMeEnt = (myId != null && sid === String(myId));
+ return isMeEnt
+ ? !!(playQuizPlayerLocal && (playQuizPlayerLocal.cannotTrue || playQuizPlayerLocal.cannotFalse))
+ : !!playQuizEverWrong[sid];
+ }
+
+ /** วาด "ผี" แทน avatar ทั้งตัวในแมป mng8a80o เมื่อตอบผิด — คุมผีเลย
+ * args ทุกตัวเป็น "screen pixels" แล้ว (คำนวณจาก worldToScreen ที่ caller)
+ * - sxCenter = พิกัด X กึ่งกลาง avatar บนจอ (px)
+ * - syBottom = พิกัด Y เท้า avatar บนจอ (px)
+ * - heightPx = ความสูง avatar บนจอ (px) ใช้คุมขนาดผีให้พอดี
+ */
+ function drawQuizWrongGhostOverlayPlay(sxCenter, syBottom, heightPx, entId, nameLabel) {
+ const img = getQuizWrongGhostImg();
+ if (!img || !img.complete || !img.naturalWidth) return;
+ /* ผีสูงเท่า avatar ตามจริง — คงสัดส่วนภาพ */
+ const hPx = Math.max(24, Number(heightPx) || 0);
+ const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
+ const wPx = hPx * ratio;
+ const sid = String(entId == null ? '' : entId);
+ const tBob = Math.sin(performance.now() / 360 + (sid ? sid.charCodeAt(0) || 0 : 0)) * (hPx * 0.04);
+ /* วาดให้ "เท้าผี" ตรงกับเท้า avatar */
+ const drawX = sxCenter - wPx / 2;
+ const drawY = syBottom - hPx + tBob;
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.drawImage(img, drawX, drawY, wPx, hPx);
+ ctx.restore();
+ /* ชื่อใต้ผี (ถ้ามี — เพื่อระบุว่าเป็นใคร) */
+ if (nameLabel) {
+ ctx.save();
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(255,255,255,0.95)';
+ ctx.strokeStyle = 'rgba(0,0,0,0.85)';
+ ctx.lineWidth = 3;
+ const ny = syBottom + 14;
+ ctx.strokeText(nameLabel, sxCenter, ny);
+ ctx.fillText(nameLabel, sxCenter, ny);
+ ctx.restore();
+ }
+ }
+ /** ช่วยแปลง tile coords → screen pos สำหรับ ghost (ใช้เฉพาะที่ caller ใน render scope)
+ * รับ helpers ที่เป็น scope-local (worldToScreen, zDraw) เพราะถูกเรียกในรอบ draw เท่านั้น */
+ function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) {
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cxWorld = (axTile + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ayTile + ch) * tileSize;
+ const [sx, sy] = w2s(cxWorld, cyBottomWorld);
+ /* ความสูง = สูง avatar บนจอ (สอดคล้องกับ drawAvatar: boxH = tileSize * zDraw * ch * 1.35) */
+ const sizeH = tileSize * zDrawIn * Math.max(ch, 1) * 1.35;
+ return { sx, sy, sizeH };
+ }
+ /** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */
+ const QUIZ_TF_POINTS_PER_CORRECT = 10;
+ const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png';
+ const QUIZ_TF_SCORE_POPUP_MS = 1700;
+ /** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */
+ const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7;
+ const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112;
+ let quizTfScorePopups = [];
+ let quizTfScorePlusImg = null;
+ /** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
+ const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
+ /** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */
+ const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000;
+ /** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
+ let quizCarryPool = [];
+ let quizCarryCurrent = null;
+ /** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
+ let quizCarryEmbedPendingQuestion = null;
+ let quizCarryEmbedCountdownStartAt = 0;
+ let quizCarryEmbedCountdownEndAt = 0;
+ /** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */
+ let quizCarryEmbedPreOptionCountdownStartAt = 0;
+ let quizCarryEmbedPreOptionCountdownEndAt = 0;
+ /** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
+ let quizCarryOptionRevealAt = 0;
+ let quizCarryAnswerCloseAt = 0;
+ /** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */
+ let quizCarryAnswerTimeupAwaitNext = false;
+ let quizCarryAnswerRoundTimeupAutoT = null;
+ let quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
+ /** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */
+ let quizCarrySessionLength = 0;
+ let quizCarryRoundsCompleted = 0;
+ let quizCarrySessionEnded = false;
+ /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */
+ let quizCarryResultEndTimer = null;
+ /** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */
+ let embedPreviewLobbyReturnTimer = null;
+ /** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */
+ let quizCarrySessionCompleteResultToSummaryT = null;
+ /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */
+ let quizCarryPregameActive = false;
+ let playHostId = null;
+ /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */
+ let quizCarryLobbyReadyMap = {};
+ /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */
+ let quizCarryMapPanelTheme = null;
+ /** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */
+ let quizMapPanelTheme = null;
+ /** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */
+ let quizCarryEmbedCountdownTheme = null;
+ /** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */
+ let quizCarryChoicePlaqueThemes = null;
+ /** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */
+ let quizCarryPlaqueMapScale = 1.25;
+ const quizCarryChoiceImageCache = new Map();
+ /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */
+ let quizCarryJoinSettingsSnap = null;
+
+ /** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */
+ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
+
+ /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */
+ let jumpSurviveCamCenterX = 0;
+ let jumpSurviveCamCenterY = 0;
+ let jumpSurviveEliminated = false;
+ /** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
+ let jumpSurviveSessionStartMs = 0;
+ let jumpSurvivePlatformScrollPx = 0;
+ let jumpSurviveVy = 0;
+ let jumpSurviveLastTickMs = 0;
+ let jumpSurviveJumpQueued = false;
+ let jumpSurviveOnGround = false;
+ let jumpSurviveLastEmitT = 0;
+ /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */
+ let jumpSurviveMissionPhase = null;
+ let jumpSurviveGameEnded = false;
+ let jumperMissionCountdownTimer = null;
+
+ /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
+ let spaceShooterBullets = [];
+ let spaceShooterAsteroids = [];
+ /** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */
+ let spaceShooterAsteroidExplosions = [];
+ let spaceShooterPopups = [];
+ let spaceShooterLastTickMs = 0;
+ let spaceShooterSpawnAccMs = 0;
+ let spaceShooterFireCd = 0;
+ let spaceShooterSessionStartMs = 0;
+ let spaceShooterLastMoveEmit = 0;
+ let spaceShooterGameEnded = false;
+ let spaceShooterMissionPhase = null;
+ let spaceShooterMissionCountdownTimer = null;
+
+ /** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */
+ let quizQuestionMissionPhase = null;
+ let quizQuestionMissionCountdownTimer = null;
+ let quizQuestionMissionDeferredPhase = null;
+ /** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */
+ let quizQuestionMissionJoyVecX = 0;
+ let quizQuestionMissionJoyVecY = 0;
+ let quizQuestionMissionJoyPointerId = null;
+ /** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */
+ let stackTowerMissionPhase = null;
+ let stackTowerMissionCountdownTimer = null;
+ let stackTowerMissionDeferredPhase = null;
+ let stackTowerSessionStartAt = 0;
+ let stackTowerMissionEndedOnce = false;
+ /** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */
+ let stackTowerPost50AnimStartMs = null;
+ /** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */
+ let stackTowerResultFlashTimer = null;
+ const STACK_TOWER_RESULT_FLASH_MS = 5000;
+
+ /** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */
+ function spaceShooterAsteroidLiveSpriteIndexPlay(a) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || !urls.length) return 0;
+ const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp);
+ if (hp >= maxHp) return 0;
+ if (urls.length >= 3) return 1;
+ return 0;
+ }
+
+ /** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */
+ function spaceShooterAsteroidExplosionStartFiPlay() {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return 0;
+ return urls.length >= 3 ? 1 : 0;
+ }
+
+ function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) return;
+ spaceShooterAsteroidExplosions.push({
+ x: worldX,
+ y: worldY,
+ r: Math.max(6, Number(r) || 14),
+ fi: spaceShooterAsteroidExplosionStartFiPlay(),
+ acc: 0,
+ });
+ }
+
+ function spaceShooterTickAsteroidExplosions(dt) {
+ const urls = playSpaceShooterAsteroidSpriteUrls;
+ if (!urls || urls.length < 2) {
+ spaceShooterAsteroidExplosions.length = 0;
+ return;
+ }
+ const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70));
+ const maxFi = urls.length - 2;
+ for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) {
+ const ex = spaceShooterAsteroidExplosions[i];
+ ex.acc += dt * 1000;
+ while (ex.acc >= fms) {
+ ex.acc -= fms;
+ ex.fi++;
+ if (ex.fi > maxFi) {
+ spaceShooterAsteroidExplosions.splice(i, 1);
+ break;
+ }
+ }
+ }
+ }
+
+ /** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
+ let balloonBossPendingShots = [];
+ let balloonBossPlayerBullets = [];
+ let balloonBossBossBullets = [];
+ let balloonBossSessionStartMs = 0;
+ let balloonBossLastTickMs = 0;
+ let balloonBossLastMoveEmit = 0;
+ let balloonBossPlayerFireCd = 0;
+ let balloonBossGameEnded = false;
+ let balloonBossHitFx = [];
+ /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */
+ let balloonBossScorePopups = [];
+ let balloonBossBossFireAcc = 0;
+ /** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
+ let quizBattleMcqPool = [];
+ let quizBattleAnsweredComps = new Set();
+ let quizBattleModalCompId = null;
+ let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง)
+ /** ประวัติคำตอบต่อโดม (สำหรับ MY RESULT): compId → { qIndex, firstTry(bool), correct(bool), submittedIdx, q } */
+ let quizBattleAnswerLog = new Map();
+ /** เวลาเริ่มเล่นห้อง quiz_battle (ms) — สำหรับ Total time */
+ let quizBattleSessionStartMs = 0;
+ let quizBattleScoreSubmittedAt = 0;
+ /** ล็อกตัวเลือกระหว่างโชว์ผลถูก/ผิด (กันกดซ้ำ + กันเด้งซ้ำ) */
+ let quizBattleChoiceLocked = false;
+ /** โชว์ popup Completed แล้วหรือยัง (กันเด้งซ้ำเมื่อตอบครบทุกโดม) */
+ let quizBattleCompletedShown = false;
+ /** ถึงจุดจบแล้ว — ล็อกตำแหน่ง ไม่ให้ echo/restore เด้งกลับ start หรือจุดเดิม */
+ let quizBattleFinishLock = false;
+ /** โดมที่ "ผ่านแล้ว" (ตอบถูกหรือผิดก็ผ่าน) — ใช้ปลดล็อกเส้นทาง + จำว่าเคยตอบ ไม่ต้องตอบซ้ำ */
+ let quizBattlePassedComps = new Set();
+ /** ซูมที่ผู้เล่นปรับเองในฉาก quiz_battle (ล้อเมาส์ / ปุ่ม +−) */
+ let quizBattleUserZoomMul = 1;
+ const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
+
+ document.getElementById('room-id').textContent = spaceId;
+
+ function hidePlayQuizHud() {
+ const sb = document.getElementById('play-quiz-scoreboard');
+ if (sb) sb.classList.add('is-hidden');
+ const fb = document.getElementById('play-quiz-feedback');
+ if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
+ }
+
+ function renderPlayQuizScoreboard(scores) {
+ const wrap = document.getElementById('play-quiz-scoreboard');
+ const ul = document.getElementById('play-quiz-scoreboard-list');
+ if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return;
+ if (!scores || typeof scores !== 'object') return;
+ wrap.classList.remove('is-hidden');
+ ul.textContent = '';
+ const merged = { ...scores };
+ others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
+ if (myId != null && merged[myId] == null) merged[myId] = 0;
+ const rows = [];
+ if (myId != null) {
+ rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
+ }
+ others.forEach((o, id) => {
+ rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
+ });
+ rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
+ rows.forEach((row) => {
+ const li = document.createElement('li');
+ if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
+ const spN = document.createElement('span');
+ spN.className = 'play-quiz-scoreboard-name';
+ spN.textContent = row.nick;
+ const spV = document.createElement('span');
+ spV.className = 'play-quiz-scoreboard-val';
+ spV.textContent = String(row.sc);
+ li.appendChild(spN);
+ li.appendChild(spV);
+ ul.appendChild(li);
+ });
+ if (isQuizCarry() && useCyberPlayHud()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */
+ if (isQuizQuestionMissionUiMapPlay()) {
+ wrap.classList.add('is-hidden');
+ }
+ /* Quiz Battle: ใช้ HUD ใหม่ (#qb-game-hud) — ซ่อนกล่อง "คะแนน" เดิม */
+ if (isQuizBattle()) {
+ wrap.classList.add('is-hidden');
+ }
+ }
+
+ function initPlayLiveQuizScoresZeros() {
+ if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return;
+ playLiveQuizScores = {};
+ playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (isQuiz()) playQuizEverWrong = {};
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ function showPlayQuizFeedback(r) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el || !r || !r.results || myId == null) return;
+ let mine = null;
+ let botRight = 0, botWrong = 0;
+ for (let i = 0; i < r.results.length; i++) {
+ const row = r.results[i];
+ if (row.id === myId) mine = row;
+ else if (isPreviewBotId(row.id)) {
+ if (row.right) botRight++;
+ else botWrong++;
+ }
+ }
+ if (!mine) return;
+ el.classList.remove('is-hidden');
+ const botExtra = playBotsEnabled() && (botRight + botWrong > 0)
+ ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
+ : '';
+ if (mine.right) {
+ el.className = 'play-quiz-feedback play-quiz-feedback-ok';
+ el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra;
+ } else {
+ el.className = 'play-quiz-feedback play-quiz-feedback-bad';
+ el.textContent = (mine.choice == null
+ ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด'
+ : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra;
+ }
+ if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT);
+ window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200);
+ }
+
+ function ensureQuizTfScorePlusImgPlay() {
+ if (quizTfScorePlusImg && quizTfScorePlusImg.src) return;
+ quizTfScorePlusImg = new Image();
+ quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL;
+ quizTfScorePlusImg.onerror = function () {
+ this.onerror = null;
+ };
+ }
+
+ /** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */
+ function spawnQuizTrueFalseScorePopupPlay(correctTrue) {
+ if (!isQuiz() || !mapData) return;
+ const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea;
+ const b = getTileBoundsForOnesGrid(grid);
+ if (!b) return;
+ const ts = tileSize || 32;
+ const wx = ((b.minX + b.maxX + 1) / 2) * ts;
+ const wy = ((b.minY + b.maxY + 1) / 2) * ts;
+ const now = Date.now();
+ ensureQuizTfScorePlusImgPlay();
+ quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS });
+ }
+
+ function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!quizTfScorePopups.length) return;
+ const img = quizTfScorePlusImg;
+ if (!img || !img.complete || !img.naturalWidth) return;
+ const now = typeof timeMs === 'number' ? timeMs : Date.now();
+ quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now);
+ const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT);
+ for (let i = 0; i < quizTfScorePopups.length; i++) {
+ const p = quizTfScorePopups[i];
+ const span = Math.max(1, p.untilMs - p.startMs);
+ const t = Math.max(0, Math.min(1, (now - p.startMs) / span));
+ const [sx, sy] = worldToScreen(p.wx, p.wy);
+ const rise = t * 56 * zDraw;
+ const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28);
+ const pulse = 1 + Math.sin(t * Math.PI) * 0.1;
+ const scale = (baseW / img.naturalWidth) * pulse;
+ const w = img.naturalWidth * scale;
+ const h = img.naturalHeight * scale;
+ ctx.save();
+ ctx.globalAlpha = fade;
+ ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h);
+ ctx.restore();
+ }
+ }
+
+ function getTileBoundsForOnesGrid(grid) {
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ function getQuizQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ /** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */
+ function getQuizCarryQuestionAreaTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ return getTileBoundsForOnesGrid(md.quizQuestionArea);
+ }
+
+ function clearQuizMapPanelThemeInline(panel, textEl) {
+ if (!panel || !textEl) return;
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ const qKick = document.getElementById('quiz-map-question-kicker');
+ if (qKick) {
+ qKick.textContent = '';
+ qKick.classList.add('is-hidden');
+ qKick.setAttribute('aria-hidden', 'true');
+ }
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ panel.style.removeProperty('--qmap-bg');
+ panel.style.removeProperty('--qmap-border');
+ panel.style.removeProperty('--qmap-border-w');
+ panel.style.removeProperty('--qmap-shadow');
+ panel.style.removeProperty('background');
+ panel.style.removeProperty('border');
+ panel.style.removeProperty('border-color');
+ panel.style.removeProperty('border-width');
+ panel.style.removeProperty('border-style');
+ panel.style.removeProperty('box-shadow');
+ textEl.style.removeProperty('--qmap-text');
+ textEl.style.removeProperty('--qmap-text-shadow');
+ textEl.style.removeProperty('color');
+ textEl.style.removeProperty('text-shadow');
+ textEl.style.removeProperty('font-size');
+ textEl.style.removeProperty('line-height');
+ textEl.style.removeProperty('width');
+ textEl.style.removeProperty('box-sizing');
+ textEl.style.removeProperty('max-height');
+ textEl.style.removeProperty('overflow');
+ }
+
+ function clearQuizMapQuestionCarryFeedback() {
+ if (typeof window.__quizMapCarryFeedbackT === 'number') {
+ clearTimeout(window.__quizMapCarryFeedbackT);
+ window.__quizMapCarryFeedbackT = null;
+ }
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (icon) {
+ icon.classList.add('is-hidden');
+ icon.removeAttribute('src');
+ }
+ if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ /** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */
+ function showQuizMapQuestionCarryFeedback(isCorrect) {
+ if (!isQuizCarry()) return;
+ const panel = document.getElementById('quiz-map-question-panel');
+ const icon = document.getElementById('quiz-map-q-feedback-icon');
+ const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
+ const scoreEl = document.getElementById('quiz-map-q-feedback-score');
+ if (!panel || !icon || panel.classList.contains('is-hidden')) return;
+ clearQuizMapQuestionCarryFeedback();
+ icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
+ icon.classList.remove('is-hidden');
+ if (isCorrect && scoreWrap && scoreEl) {
+ scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus;
+ scoreWrap.classList.remove('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'false');
+ scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
+ void scoreEl.offsetWidth;
+ scoreEl.classList.add('quiz-map-q-feedback-score--pop');
+ } else if (scoreWrap) {
+ scoreWrap.classList.add('is-hidden');
+ scoreWrap.setAttribute('aria-hidden', 'true');
+ }
+ window.__quizMapCarryFeedbackT = setTimeout(() => {
+ window.__quizMapCarryFeedbackT = null;
+ clearQuizMapQuestionCarryFeedback();
+ }, QUIZ_MAP_CARRY_FEEDBACK_MS);
+ }
+
+ /** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */
+ function defaultCarryMapPanelThemePlay() {
+ return {
+ panelBg: 'rgba(12, 14, 28, 0.88)',
+ panelBorder: 'rgba(255, 214, 102, 0.7)',
+ borderWidthPx: 2,
+ textColor: '#f1f5f9',
+ questionFontMinPx: 10,
+ questionFontMaxPx: 24,
+ };
+ }
+
+ /** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */
+ function quizCarryPlaqueMapScaleClampedPlay() {
+ const sc = Number(quizCarryPlaqueMapScale);
+ if (!Number.isFinite(sc)) return 1.25;
+ return Math.max(0.85, Math.min(2.5, sc));
+ }
+
+ /**
+ * ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps))
+ * แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max)
+ */
+ function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) {
+ const ts = tileSize * zoomVal;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const base = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const fb = defaultCarryMapPanelThemePlay();
+ let mn = Number(th && th.questionFontMinPx);
+ let mx = Number(th && th.questionFontMaxPx);
+ if (!Number.isFinite(mn)) mn = fb.questionFontMinPx;
+ if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx;
+ mn = Math.round(Math.max(8, Math.min(40, mn)));
+ mx = Math.round(Math.max(10, Math.min(72, mx)));
+ if (mx < mn) {
+ const s = mn;
+ mn = mx;
+ mx = s;
+ }
+ return Math.max(mn, Math.min(mx, base));
+ }
+
+ /**
+ * ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar)
+ * zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด
+ * เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน
+ */
+ function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) {
+ const cs = window.getComputedStyle(panel);
+ const padT = parseFloat(cs.paddingTop) || 0;
+ const padB = parseFloat(cs.paddingBottom) || 0;
+ const padL = parseFloat(cs.paddingLeft) || 0;
+ const padR = parseFloat(cs.paddingRight) || 0;
+ let kickerReserve = 0;
+ if (panel.classList.contains('quiz-map-question-panel--question-mission-live')
+ && panel.classList.contains('quiz-map-question-panel--has-kicker')) {
+ kickerReserve = 30;
+ }
+ const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve);
+ const availW = Math.max(24, panel.clientWidth - padL - padR);
+ const themeMin = Math.max(8, Math.floor(Number(minPx) || 8));
+ const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7)));
+ let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin)));
+ textEl.style.setProperty('line-height', '1.28', 'important');
+ textEl.style.setProperty('width', '100%', 'important');
+ textEl.style.setProperty('box-sizing', 'border-box', 'important');
+ textEl.style.removeProperty('max-height');
+ textEl.style.setProperty('overflow', 'hidden', 'important');
+ for (let i = 0; i < 96 && px > hardMin; i++) {
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ const sh = textEl.scrollHeight;
+ const sw = textEl.scrollWidth;
+ if (sh <= availH + 2 && sw <= availW + 2) break;
+ px -= 1;
+ }
+ textEl.style.setProperty('font-size', String(px) + 'px', 'important');
+ textEl.style.setProperty('max-height', availH + 'px', 'important');
+ }
+
+ /** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */
+ function parseCarryMapPanelThemeObject(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = Number(t.questionFontMinPx);
+ let qMax = Number(t.questionFontMaxPx);
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ return {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ questionFontMinPx: qMin,
+ questionFontMaxPx: qMax,
+ };
+ }
+
+ /**
+ * ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง
+ * (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24)
+ */
+ function parseCarryMapPanelThemeMapOverlay(raw) {
+ let t = raw;
+ if (typeof t === 'string') {
+ try {
+ t = JSON.parse(t);
+ } catch (e) {
+ t = null;
+ }
+ }
+ if (!t || typeof t !== 'object') return null;
+ const bw = Number(t.borderWidthPx);
+ const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
+ const out = {
+ panelBg: String(t.panelBg || '').trim().slice(0, 120),
+ panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
+ borderWidthPx,
+ textColor: String(t.textColor || '').trim().slice(0, 120),
+ };
+ const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx');
+ const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx');
+ if (hasMin || hasMax) {
+ const fb = defaultCarryMapPanelThemePlay();
+ let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx;
+ let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx;
+ if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
+ if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
+ qMin = Math.round(Math.max(10, Math.min(40, qMin)));
+ qMax = Math.round(Math.max(14, Math.min(56, qMax)));
+ if (qMax < qMin) {
+ const s = qMin;
+ qMin = qMax;
+ qMax = s;
+ }
+ out.questionFontMinPx = qMin;
+ out.questionFontMaxPx = qMax;
+ }
+ return out;
+ }
+
+ function setQuizCarryMapPanelThemeFromApi(s) {
+ quizCarryMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme);
+ if (!parsed) return;
+ quizCarryMapPanelTheme = parsed;
+ }
+
+ function setQuizMapPanelThemeFromApi(s) {
+ quizMapPanelTheme = null;
+ if (!s || typeof s !== 'object') return;
+ const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme);
+ if (!parsed) return;
+ quizMapPanelTheme = parsed;
+ }
+
+ function defaultCarryChoicePlaqueThemePlay() {
+ return {
+ borderMode: 'neon',
+ fixedBorder: 'rgba(122, 200, 255, 0.9)',
+ fillBg: 'rgba(12, 10, 20, 0.88)',
+ textColor: 'rgba(248, 249, 255, 1)',
+ borderWidthPx: 2.5,
+ plaqueImageUrl: '',
+ };
+ }
+
+ function parseCarryChoicePlaqueThemeObject(raw) {
+ const d = defaultCarryChoicePlaqueThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const safeColor = (x, fb) => {
+ const t = String(x == null ? '' : x).trim().slice(0, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
+ let bw = Number(raw.borderWidthPx);
+ if (!Number.isFinite(bw)) bw = d.borderWidthPx;
+ bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
+ return {
+ borderMode: mode,
+ fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder),
+ fillBg: safeColor(raw.fillBg, d.fillBg),
+ textColor: safeColor(raw.textColor, d.textColor),
+ borderWidthPx: bw,
+ plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl),
+ };
+ }
+
+ function buildCarryChoicePlaqueThemesArrayFromApi(s) {
+ if (!s || typeof s !== 'object') return null;
+ if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) {
+ const out = [];
+ for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) {
+ out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i]));
+ }
+ return out;
+ }
+ if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') {
+ const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme);
+ return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one }));
+ }
+ return null;
+ }
+
+ function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) {
+ const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0));
+ if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) {
+ return quizCarryChoicePlaqueThemes[i];
+ }
+ return defaultCarryChoicePlaqueThemePlay();
+ }
+
+ function setQuizCarryChoicePlaqueThemeFromApi(s) {
+ quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s);
+ }
+
+ function sanitizeQuizCarryImageUrlClient(u) {
+ const s = String(u == null ? '' : u).trim().slice(0, 512);
+ if (!s || /[\s<>'"`]/.test(s)) return '';
+ if (s.startsWith('/')) {
+ if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
+ return s;
+ }
+ const low = s.toLowerCase();
+ if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
+ try {
+ const parsed = new URL(s);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
+ return s;
+ } catch (e) {
+ return '';
+ }
+ }
+
+ /** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */
+ function quizCarryApplyImageCrossOrigin(img, rawUrl) {
+ if (!img) return;
+ try {
+ img.removeAttribute('crossorigin');
+ const u = sanitizeQuizCarryImageUrlClient(rawUrl);
+ if (!u || u.startsWith('data:')) return;
+ let imgOrigin = '';
+ if (u.startsWith('http://') || u.startsWith('https://')) {
+ imgOrigin = new URL(u).origin;
+ } else if (u.startsWith('/')) {
+ imgOrigin = window.location.origin;
+ }
+ if (imgOrigin && imgOrigin !== window.location.origin) {
+ img.crossOrigin = 'anonymous';
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function preloadQuizCarryChoiceImages(q) {
+ if (!q) return;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ for (let i = 0; i < q.choiceImageUrls.length; i++) {
+ const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]);
+ if (!raw) continue;
+ if (quizCarryChoiceImageCache.has(raw)) continue;
+ const im = new Image();
+ quizCarryApplyImageCrossOrigin(im, raw);
+ im.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw, im);
+ try {
+ im.src = raw;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ if (Array.isArray(q.choices)) {
+ for (let j = 0; j < q.choices.length; j++) {
+ const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j);
+ if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue;
+ const im2 = new Image();
+ quizCarryApplyImageCrossOrigin(im2, raw2);
+ im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(raw2, im2);
+ try {
+ im2.src = raw2;
+ } catch (e) { /* ignore */ }
+ }
+ }
+ }
+
+ function getQuizCarryChoiceImageCached(url) {
+ const u = sanitizeQuizCarryImageUrlClient(url);
+ if (!u) return null;
+ const im = quizCarryChoiceImageCache.get(u);
+ if (im && im.complete && im.naturalWidth > 0) return im;
+ return null;
+ }
+
+ function getQuizCarryChoiceImageUrlForIndex(q, idx) {
+ if (!q || !Array.isArray(q.choiceImageUrls)) return '';
+ const u = q.choiceImageUrls[idx];
+ return sanitizeQuizCarryImageUrlClient(u);
+ }
+
+ /** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */
+ function getQuizCarryPlaqueImageUrlForIndex(q, idx) {
+ const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx);
+ if (perQ) return perQ;
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx);
+ const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : '';
+ return u || '';
+ }
+
+ function defaultCarryEmbedCountdownThemePlay() {
+ return {
+ overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
+ innerBg: 'rgba(0, 0, 0, 0.78)',
+ innerBorder: 'rgba(94, 234, 212, 0.82)',
+ innerBorderWpx: 1,
+ innerRadiusPx: 14,
+ digitColor: '#ffe066',
+ mapDigitCqmin: 78,
+ mapDigitCqh: 82,
+ mapDigitMaxPx: 220,
+ screenDigitVw: 32,
+ screenDigitMaxPx: 200,
+ };
+ }
+
+ function parseCarryEmbedCountdownThemeObject(raw) {
+ const d = defaultCarryEmbedCountdownThemePlay();
+ if (!raw || typeof raw !== 'object') return d;
+ const clampN = (v, lo, hi, def) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return def;
+ return Math.max(lo, Math.min(hi, Math.round(n)));
+ };
+ const clipStr = (x, maxLen) => {
+ const t = String(x == null ? '' : x).trim().slice(0, maxLen);
+ return t || null;
+ };
+ const safeColor = (x, fb) => {
+ const t = clipStr(x, 120);
+ if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
+ if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
+ return fb;
+ };
+ return {
+ overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop),
+ innerBg: safeColor(raw.innerBg, d.innerBg),
+ innerBorder: safeColor(raw.innerBorder, d.innerBorder),
+ innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
+ innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
+ digitColor: safeColor(raw.digitColor, d.digitColor),
+ mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
+ mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
+ mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
+ screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
+ screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
+ };
+ }
+
+ function setQuizCarryEmbedCountdownThemeFromApi(s) {
+ quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme);
+ applyQuizCarryEmbedCountdownThemeToDom();
+ }
+
+ const CARRY_ECD_VAR_KEYS = [
+ '--carry-ecd-overlay',
+ '--carry-ecd-inner-bg',
+ '--carry-ecd-inner-border',
+ '--carry-ecd-inner-border-w',
+ '--carry-ecd-inner-radius',
+ '--carry-ecd-digit-color',
+ '--carry-ecd-map-digit-fs',
+ '--carry-ecd-screen-vw',
+ '--carry-ecd-screen-max',
+ ];
+
+ function clearQuizCarryEmbedCountdownThemeDomVars() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ if (!root) return;
+ CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } });
+ }
+
+ /** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */
+ function applyQuizCarryEmbedCountdownThemeToDom() {
+ const root = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+ if (!root) return;
+ clearQuizCarryEmbedCountdownThemeDomVars();
+ const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay();
+ root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop);
+ root.style.setProperty('--carry-ecd-inner-bg', th.innerBg);
+ root.style.setProperty('--carry-ecd-inner-border', th.innerBorder);
+ root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`);
+ root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`);
+ root.style.setProperty('--carry-ecd-digit-color', th.digitColor);
+ const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78)));
+ const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82)));
+ const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200)));
+ root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`);
+ const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28)));
+ const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132)));
+ root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`);
+ root.style.setProperty('--carry-ecd-screen-max', `${smx}px`);
+ if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') {
+ numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)';
+ } else if (numEl) {
+ try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ }
+ }
+ }
+
+ function setCountdown321QuestionAssetGraphic(numEl, n) {
+ if (!numEl) return;
+ const d = Math.max(1, Math.min(3, n));
+ if (String(numEl.tagName || '').toUpperCase() === 'IMG') {
+ numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png';
+ numEl.alt = String(d);
+ } else {
+ numEl.textContent = String(d);
+ }
+ }
+
+ /** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */
+ function getEffectiveCarryMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.carryMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ /** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */
+ function getEffectiveQuizMapPanelThemeForApply() {
+ const d = defaultCarryMapPanelThemePlay();
+ const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null;
+ let merged = api ? { ...d, ...api } : { ...d };
+ if (mapData && mapData.quizMapPanelTheme != null) {
+ const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme);
+ if (mp) merged = { ...merged, ...mp };
+ }
+ return merged;
+ }
+
+ function paintQuizMapPanelTheme(panel, textEl, th) {
+ if (!panel || !textEl || !th) return;
+ clearQuizMapPanelThemeInline(panel, textEl);
+ const bgStr = th.panelBg != null ? String(th.panelBg).trim() : '';
+ const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : '';
+ const txStr = th.textColor != null ? String(th.textColor).trim() : '';
+ const wRaw = Number(th.borderWidthPx);
+ const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0;
+ const bgUse = bgStr || 'transparent';
+ const brUse = brStr || 'transparent';
+ const txUse = txStr || '#f1f5f9';
+ panel.style.setProperty('--qmap-bg', bgUse);
+ panel.style.setProperty('--qmap-border', brUse);
+ panel.style.setProperty('--qmap-border-w', String(w) + 'px');
+ panel.style.setProperty('--qmap-shadow', 'none');
+ textEl.style.setProperty('--qmap-text', txUse);
+ textEl.style.setProperty('--qmap-text-shadow', 'none');
+ /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */
+ panel.style.setProperty('background', bgUse, 'important');
+ panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important');
+ panel.style.setProperty('box-shadow', 'none', 'important');
+ textEl.style.setProperty('color', txUse, 'important');
+ textEl.style.setProperty('text-shadow', 'none', 'important');
+ }
+
+ function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuizCarry()) {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply());
+ }
+
+ function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) {
+ if (!panel || !textEl) return;
+ if (!isQuiz() || isQuizCarry()) return;
+ paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply());
+ }
+
+ function syncPlayQuizMapPanel() {
+ const panel = document.getElementById('quiz-map-question-panel');
+ const textEl = document.getElementById('quiz-map-question-text');
+ if (!panel || !textEl || !mapData) return;
+ /* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */
+ if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (isQuizBattle()) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ if (!isQuiz() && !isQuizCarry()) {
+ clearQuizMapQuestionCarryFeedback();
+ return;
+ }
+ const bounds = isQuiz()
+ ? getQuizQuestionAreaTileBounds(mapData)
+ : (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData));
+ const text = (playQuizText || '').trim();
+ if (!bounds || !text) {
+ clearQuizMapQuestionCarryFeedback();
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, textEl);
+ return;
+ }
+ const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ textEl.textContent = text;
+ panel.style.left = Math.round(left) + 'px';
+ panel.style.top = Math.round(top) + 'px';
+ panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ panel.classList.remove('is-hidden');
+ panel.setAttribute('aria-hidden', 'false');
+ if (isQuiz() && !isQuizCarry()) {
+ panel.classList.add('quiz-map-question-panel--true-false');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--true-false');
+ }
+ if (isQuizCarry()) {
+ applyQuizCarryMapPanelThemeIfNeeded(panel, textEl);
+ } else if (isQuiz()) {
+ applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl);
+ } else {
+ clearQuizMapPanelThemeInline(panel, textEl);
+ }
+ const thFont = isQuizCarry()
+ ? getEffectiveCarryMapPanelThemeForApply()
+ : getEffectiveQuizMapPanelThemeForApply();
+ const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom);
+ const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10));
+ /* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') {
+ panel.classList.add('quiz-map-question-panel--question-mission-live');
+ } else {
+ panel.classList.remove('quiz-map-question-panel--question-mission-live');
+ if (panel.dataset.qmPlaqueTry) {
+ panel.style.removeProperty('background-image');
+ panel.style.removeProperty('background-size');
+ panel.style.removeProperty('background-position');
+ delete panel.dataset.qmPlaqueTry;
+ }
+ }
+ panel.classList.toggle(
+ 'quiz-map-question-panel--tf-mock-qz',
+ !!(isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live' && isMissionMockHudPlay()),
+ );
+ const kickerEl = document.getElementById('quiz-map-question-kicker');
+ const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer';
+ if (kickerEl) {
+ let showK = false;
+ let line = '';
+ if (missionLiveQ && inReadOrAnswer) {
+ const qi = Math.max(0, Number(playQuizQuestionIndex) || 0);
+ const qt = Math.max(0, Number(playQuizQuestionTotal) || 0);
+ if (qi > 0 && qt > 0) {
+ line = '- Quiz ' + qi + ' / ' + qt + ' -';
+ showK = true;
+ } else if (qi > 0) {
+ line = '- Quiz ' + qi + ' -';
+ showK = true;
+ }
+ }
+ if (showK && line) {
+ kickerEl.textContent = line;
+ kickerEl.classList.remove('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'false');
+ panel.classList.add('quiz-map-question-panel--has-kicker');
+ } else {
+ kickerEl.textContent = '';
+ kickerEl.classList.add('is-hidden');
+ kickerEl.setAttribute('aria-hidden', 'true');
+ panel.classList.remove('quiz-map-question-panel--has-kicker');
+ }
+ }
+ fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont);
+ }
+
+ /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */
+ function syncQuizCarryTimeupDeskLayerPosition() {
+ const layer = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!layer || layer.classList.contains('is-hidden')) return;
+ const anchor = layer.querySelector('.qc-timeup-desk-anchor')
+ || layer.querySelector('.qc-timeup-desk-inner')
+ || (() => {
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null;
+ })();
+ if (!anchor || !canvas) return;
+ const clearToFullStack = () => {
+ anchor.style.left = '0';
+ anchor.style.top = '0';
+ anchor.style.width = '100%';
+ anchor.style.height = '100%';
+ };
+ const stack = document.getElementById('play-canvas-stack');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (stack && panel && !panel.classList.contains('is-hidden')) {
+ try {
+ const sr = stack.getBoundingClientRect();
+ const pr = panel.getBoundingClientRect();
+ const rw = pr.width;
+ const rh = pr.height;
+ if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) {
+ anchor.style.left = Math.round(pr.left - sr.left) + 'px';
+ anchor.style.top = Math.round(pr.top - sr.top) + 'px';
+ anchor.style.width = Math.round(rw) + 'px';
+ anchor.style.height = Math.round(rh) + 'px';
+ return;
+ }
+ } catch (e) { /* fallback ด้านล่าง */ }
+ }
+ const pl = panel && panel.style && String(panel.style.left || '').trim();
+ const pt = panel && panel.style && String(panel.style.top || '').trim();
+ const pw = panel && panel.style && String(panel.style.width || '').trim();
+ const ph = panel && panel.style && String(panel.style.height || '').trim();
+ if (pl && pt && pw && ph) {
+ anchor.style.left = pl;
+ anchor.style.top = pt;
+ anchor.style.width = pw;
+ anchor.style.height = ph;
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'quiz_carry') {
+ clearToFullStack();
+ return;
+ }
+ const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ clearToFullStack();
+ return;
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
+ const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ anchor.style.left = Math.round(left) + 'px';
+ anchor.style.top = Math.round(top) + 'px';
+ anchor.style.width = Math.round(Math.max(48, wPx)) + 'px';
+ anchor.style.height = Math.round(Math.max(40, hPx)) + 'px';
+ }
+
+ function carryEmbedCountdownAnchorResolved() {
+ if (!mapData || !isQuizCarry()) return 'screen';
+ const v = mapData.carryEmbedCountdownAnchor;
+ if (v === 'grid' || v === 'map') return v;
+ return 'screen';
+ }
+
+ function resetQuizCarryEmbedCountdownLayoutDom() {
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor');
+ if (inner) {
+ inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = '';
+ inner.style.left = '';
+ inner.style.top = '';
+ inner.style.width = '';
+ inner.style.height = '';
+ inner.style.transform = '';
+ inner.style.maxWidth = '';
+ inner.style.boxSizing = '';
+ inner.style.display = '';
+ inner.style.margin = '';
+ }
+ }
+
+ /** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */
+ function syncQuizCarryEmbedCountdownLayout() {
+ if (!quizCarryEmbedMissionFlowActive() || !mapData) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const inner = document.getElementById('quiz-carry-embed-countdown-inner');
+ const canvasEl = document.getElementById('game-canvas');
+ if (!ov || !inner || !canvasEl) return;
+ if (ov.classList.contains('is-hidden')) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ const anchor = carryEmbedCountdownAnchorResolved();
+ if (anchor === 'screen') {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ let bounds = null;
+ if (anchor === 'grid') {
+ bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ } else {
+ bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
+ if (!bounds) {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ return;
+ }
+ }
+ const cc = getQuizCarryMapCameraWorldCenterPxPlay();
+ const camX = cc ? cc.cx : me.x * tileSize;
+ const camY = cc ? cc.cy : me.y * tileSize;
+ const zDom = playDomSyncZoom();
+ const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2;
+ const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2;
+ const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
+ const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
+ const r = canvasEl.getBoundingClientRect();
+ const sl = r.left + l;
+ const st = r.top + t;
+ ov.classList.add('quiz-carry-embed-countdown--map-anchor');
+ inner.classList.add('quiz-carry-embed-countdown-inner--map-rect');
+ inner.style.position = 'fixed';
+ inner.style.left = Math.round(sl) + 'px';
+ inner.style.top = Math.round(st) + 'px';
+ inner.style.width = Math.round(Math.max(100, wPx)) + 'px';
+ inner.style.height = Math.round(Math.max(72, hPx)) + 'px';
+ inner.style.transform = 'none';
+ inner.style.boxSizing = 'border-box';
+ inner.style.display = 'flex';
+ inner.style.flexDirection = 'column';
+ inner.style.alignItems = 'center';
+ inner.style.justifyContent = 'center';
+ inner.style.margin = '0';
+ inner.style.maxWidth = 'none';
+ }
+
+ /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
+ function syncQuizCarryEmbedQuestionStrip() {
+ const wrap = document.getElementById('quiz-carry-embed-q-strip');
+ const p = document.getElementById('quiz-carry-embed-q-strip-text');
+ if (!wrap || !p) return;
+ wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
+ wrap.classList.add('is-hidden');
+ wrap.setAttribute('aria-hidden', 'true');
+ }
+
+ function updatePlayQuizTimerDisplay() {
+ const el = document.getElementById('quiz-game-timer');
+ if (!el) return;
+ if (!playQuizPhaseEndsAt) {
+ el.textContent = '';
+ return;
+ }
+ const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
+ el.textContent = s + ' วินาที';
+ }
+
+ function clampPreviewMs(n, def, minV, maxV) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(minV, Math.min(maxV, Math.floor(v)));
+ }
+
+ function clampCarrySessionLen(n, def) {
+ const v = Number(n);
+ if (Number.isNaN(v)) return def;
+ return Math.max(0, Math.min(500, Math.floor(v)));
+ }
+
+ function buildQuizPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ return md.quizQuestions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+
+ function shufflePreviewQuizQuestionsPlay(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;
+ }
+
+ /** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */
+ function clampPreviewQuizRoundQuestionCount(settings) {
+ const v = Number(settings && settings.quizRoundQuestionCount);
+ if (!Number.isFinite(v)) return 10;
+ return Math.max(1, Math.min(50, Math.floor(v)));
+ }
+
+ function finishPreviewQuizSessionPlay() {
+ previewQuizStep = 'done';
+ playQuizPhaseLocal = null;
+ previewQuizCurrent = null;
+ playQuizPhaseEndsAt = 0;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) qov.classList.add('is-hidden');
+ const mapPanel = document.getElementById('quiz-map-question-panel');
+ if (mapPanel) {
+ mapPanel.classList.add('is-hidden');
+ mapPanel.setAttribute('aria-hidden', 'true');
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = 'ended';
+ applyQuizQuestionMissionPanelImages();
+ const mission = quizQuestionMissionBuildPayload();
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว';
+ if (qov) qov.classList.remove('is-hidden');
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)';
+ }
+
+ function clearPreviewBotAnswerPaths() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function resetPreviewBotsQuizState() {
+ if (!playBotsEnabled()) return;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.quizCannotTrue = false;
+ o.quizCannotFalse = false;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ });
+ }
+
+ function applyPreviewReadPhase(q, poolLen) {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'read';
+ playQuizPhaseLocal = 'read';
+ previewQuizCurrent = q;
+ playQuizText = q.text;
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs;
+ playQuizQuestionIndex = previewQuizQIndex + 1;
+ playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) {
+ phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen;
+ }
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ';
+ }
+
+ function applyPreviewAnswerPhase() {
+ previewQuizStep = 'answer';
+ playQuizPhaseLocal = 'answer';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg && previewQuizCurrent) {
+ leg.textContent = previewQuizCurrent.answerTrue
+ ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า'
+ : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู';
+ }
+ previewBotsPrepareAnswerRound();
+ }
+
+ function applyPreviewBetweenPhase() {
+ clearPreviewBotAnswerPaths();
+ previewQuizStep = 'between';
+ playQuizPhaseLocal = null;
+ playQuizText = 'ข้อถัดไปกำลังจะมา…';
+ playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)';
+ }
+
+ function quizCellOnPlay(grid, x, y) {
+ return !!(grid && grid[y] && grid[y][x] === 1);
+ }
+
+ /** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */
+ function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) {
+ if (!mapData || !isQuiz()) return false;
+ for (const k of quizTilesFootprintPlay(ax, ay)) {
+ const parts = k.split(',');
+ const fx = +parts[0];
+ const fy = +parts[1];
+ if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true;
+ }
+ return false;
+ }
+
+ /** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */
+ function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) {
+ if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false;
+ if (entId === myId || ent === me) {
+ return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse);
+ }
+ const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse);
+ const everWrong = !!playQuizEverWrong[String(entId)];
+ return locked || everWrong;
+ }
+
+ function spawnTileWalkablePlay(md, x, y) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ if (row && row[x] === 1) return false;
+ return true;
+ }
+
+ function spawnFootprintFitsPlay(md, anchorX, anchorY) {
+ if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false;
+ const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1)));
+ const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1)));
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const maxX = Math.min(w, anchorX + cellsW);
+ const maxY = Math.min(h, anchorY + cellsH);
+ for (let ty = anchorY; ty < maxY; ty++) {
+ for (let tx = anchorX; tx < maxX; tx++) {
+ if (!spawnTileWalkablePlay(md, tx, ty)) return false;
+ }
+ }
+ return true;
+ }
+
+ /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */
+ function pickRandomSpawnFromMapPlay(md) {
+ const fallback = md.spawn || { x: 1, y: 1 };
+ const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
+ const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return { x: fx, y: fy };
+ const u = new Uint32Array(1);
+ (typeof crypto !== 'undefined' && crypto.getRandomValues)
+ ? crypto.getRandomValues(u)
+ : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0));
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function parseLobbyPlayerSpawnsFromMapPlay(md) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const out = [null, null, null, null, null, null];
+ const raw = md && md.lobbyPlayerSpawns;
+ if (!Array.isArray(raw)) return out;
+ for (let i = 0; i < 6 && i < raw.length; i++) {
+ const cell = raw[i];
+ if (!cell || typeof cell !== 'object') continue;
+ const x = Math.floor(Number(cell.x));
+ const y = Math.floor(Number(cell.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
+ if (x < 0 || x >= w || y < 0 || y >= h) continue;
+ out[i] = { x, y };
+ }
+ return out;
+ }
+
+ /** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */
+ function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) {
+ if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
+ const g = md.shooterSpawnSlots;
+ if (!g) return;
+ const w = md.width || 20, h = md.height || 15;
+ for (let slot = 1; slot <= 6; slot++) {
+ const idx = slot - 1;
+ if (slots6[idx]) continue;
+ let found = false;
+ for (let yy = 0; yy < h && !found; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] === slot) {
+ slots6[idx] = { x: xx, y: yy };
+ found = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /** จุดเกิด Quiz Battle — ต้องอยู่บนเส้นทาง (กล่องสไปรต์) ไม่ใช่แค่ snap จาก spawnArea นอกเลน */
+ function collectQuizBattleValidSpawnCentersPlay(md) {
+ const out = [];
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) return out;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.quizBattlePathArea;
+ const seen = new Set();
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x);
+ return out;
+ }
+
+ /** จุดโลกใกล้ pref — ลองกลางช่องก่อน แล้วหาใน spawnArea (ฟ้า) ที่วางตัวได้ */
+ function quizBattleNearestValidSpawnWorldPlay(md, prefTx, prefTy) {
+ const ptx = Math.floor(Number(prefTx)) || 0;
+ const pty = Math.floor(Number(prefTy)) || 0;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) {
+ if (quizBattleSpawnWallClearPlay(md, nx, ny)) return { x: nx, y: ny };
+ }
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ if (pool.length) {
+ let best = pool[0], bestD = Infinity;
+ for (const c of pool) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ }
+ const valid = collectQuizBattleValidSpawnCentersPlay(md);
+ if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 };
+ let best = valid[0], bestD = Infinity;
+ for (const c of valid) {
+ const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty);
+ if (d < bestD) { bestD = d; best = c; }
+ }
+ return { x: best.x, y: best.y };
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** ช่อง spawnArea (ฟ้า) ที่วางตัวได้ — สุ่มในพื้นที่ฟ้า ไม่บังคับอยู่บนเลนม่วง */
+ function collectQuizBattleSpawnPoolPlay(md) {
+ const out = [];
+ if (!md) return out;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ const w = md.width || 20, h = md.height || 15;
+ const grid = md.spawnArea;
+ const seen = new Set();
+ const cells = [];
+ if (grid && Array.isArray(grid)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = grid[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (Number(row[tx]) === 1) cells.push({ tx, ty });
+ }
+ }
+ }
+ if (!cells.length && md.spawn) {
+ cells.push({
+ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)),
+ ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)),
+ });
+ }
+ for (const { tx, ty } of cells) {
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ const key = nx.toFixed(3) + ',' + ny.toFixed(3);
+ if (seen.has(key)) continue;
+ if (quizBattlePathModeActive(md)) {
+ if (!quizBattleSpawnWallClearPlay(md, nx, ny)) continue;
+ } else if (!spawnFootprintFitsPlay(md, tx, ty)) {
+ continue;
+ }
+ seen.add(key);
+ out.push({ tx, ty, x: nx, y: ny });
+ }
+ }
+ if (!out.length && quizBattlePathModeActive(md)) {
+ return collectQuizBattleValidSpawnCentersPlay(md);
+ }
+ } finally {
+ mapData = prevMap;
+ }
+ return out;
+ }
+
+ function pickQuizBattleRandomSpawnWorldPlay(md) {
+ const pool = collectQuizBattleSpawnPoolPlay(md);
+ const fb = md.spawn || { x: 1, y: 1 };
+ if (!pool.length) {
+ return quizBattleNearestValidSpawnWorldPlay(md, fb.x, fb.y);
+ }
+ const u = new Uint32Array(1);
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(u);
+ else u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0);
+ const pick = pool[u[0] % pool.length];
+ return { x: pick.x, y: pick.y };
+ }
+
+ function quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1.5, y: 1.5 };
+ const ord = joinOrderIndex | 0;
+ const mode = md.lobbySpawnMode;
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ const slot = slots[j];
+ if (slot) return quizBattleNearestValidSpawnWorldPlay(md, slot.x, slot.y);
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+ if (mode === 'fixed' && md.spawn) {
+ const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1;
+ return quizBattleNearestValidSpawnWorldPlay(md, sx, sy);
+ }
+ /* random (ค่าเริ่มต้นใน editor) — สุ่มในพื้นที่ฟ้า ไม่เรียงตาม join order */
+ return pickQuizBattleRandomSpawnWorldPlay(md);
+ }
+
+ function pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex) {
+ const world = quizBattleSpawnWorldFromJoinOrderPlay(md, joinOrderIndex);
+ return { x: Math.floor(world.x), y: Math.floor(world.y) };
+ }
+
+ /** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */
+ function pickSpawnForJoinPlay(md, joinOrderIndex) {
+ if (!md) return { x: 1, y: 1 };
+ if (md.gameType === 'quiz_battle' && quizBattlePathModeActive(md)) {
+ const qb = pickQuizBattleSpawnFromMapPlay(md, joinOrderIndex);
+ if (qb) return qb;
+ }
+ const mode = md.lobbySpawnMode;
+ const ord = joinOrderIndex | 0;
+ if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMapPlay(md);
+ const j = Math.min(Math.max(0, ord), 5);
+ if (mode === 'fixed' && md.spawn) {
+ const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
+ const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const x = Math.max(0, Math.min(w - 1, fx));
+ const y = Math.max(0, Math.min(h - 1, fy));
+ if (spawnFootprintFitsPlay(md, x, y)) return { x, y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ if (mode === 'slots6') {
+ const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
+ augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots);
+ const pick = slots[j];
+ if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
+ return pickRandomSpawnFromMapPlay(md);
+ }
+ return pickRandomSpawnFromMapPlay(md);
+ }
+
+ const GAUNTLET_PREVIEW_MAX = 6;
+ function gauntletSpawnYsPlay(md, playerCount) {
+ const h = md.height || 15;
+ const lo = 1;
+ const hi = Math.max(lo, h - 2);
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ if (hi <= lo) return Array.from({ length: n }, () => lo);
+ const ys = [];
+ for (let i = 0; i < n; i++) {
+ ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
+ }
+ return ys;
+ }
+ function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
+ const grid = md.spawnArea;
+ if (!grid || !Array.isArray(grid)) return [];
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
+ }
+ }
+ slots.sort((a, b) => a.y - b.y || a.x - b.x);
+ return slots;
+ }
+ function collectGauntletSpawnSlotsPlay(md) {
+ const explicit = md.gauntletPlayerSpawns;
+ if (Array.isArray(explicit) && explicit.length > 0) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const slots = [];
+ for (const raw of explicit) {
+ if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
+ const x = Math.floor(Number(raw && raw.x));
+ const y = Math.floor(Number(raw && raw.y));
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
+ if (!spawnTileWalkablePlay(md, x, y)) continue;
+ slots.push({ x, y });
+ }
+ if (slots.length > 0) return slots;
+ }
+ return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
+ }
+ function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
+ const w = md.width || 20;
+ if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
+ if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
+ for (let d = 0; d < w; d++) {
+ if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
+ if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
+ }
+ return Math.max(0, Math.min(w - 1, spawnColX));
+ }
+ function gauntletSpawnPositionsPlay(md, playerCount) {
+ const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
+ const slots = collectGauntletSpawnSlotsPlay(md);
+ const fallbackYs = gauntletSpawnYsPlay(md, n);
+ const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const y = i < slots.length
+ ? slots[i].y
+ : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
+ const slotX = i < slots.length ? slots[i].x : null;
+ const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
+ out.push({ x, y });
+ }
+ return out;
+ }
+ /**
+ * โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
+ * @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
+ */
+ function applyGauntletPreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ const humanCount = 1 + realIds.length;
+ const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
+ const pos = gauntletSpawnPositionsPlay(mapData, total);
+ if (onlyBots) {
+ let idx = humanCount;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ return;
+ }
+ let idx = 0;
+ if (idx < pos.length) {
+ me.x = pos[idx].x;
+ me.y = pos[idx].y;
+ me.tx = me.x;
+ me.ty = me.y;
+ idx++;
+ }
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || idx >= pos.length) return;
+ o.x = pos[idx].x;
+ o.tx = pos[idx].x;
+ o.y = pos[idx].y;
+ o.ty = pos[idx].y;
+ idx++;
+ });
+ }
+
+ function isPreviewBotId(id) {
+ return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
+ }
+
+ function countPlayHumans() {
+ let n = 1;
+ others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
+ return n;
+ }
+
+ function rebalancePreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const human = countPlayHumans();
+ const wantBots = Math.max(0, playBotTargetHeadcount() - human);
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid);
+ if (o.botTier == null) {
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ o.botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ }
+ if (o.quizCannotTrue == null) o.quizCannotTrue = false;
+ if (o.quizCannotFalse == null) o.quizCannotFalse = false;
+ if (!Array.isArray(o.botPath)) o.botPath = [];
+ if (o.botAnswerWander == null) o.botAnswerWander = false;
+ if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ if (o.gauntletScore == null) {
+ o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0;
+ }
+ if (o.gauntletEliminated == null) o.gauntletEliminated = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ o.botWanderDx = wd[0];
+ o.botWanderDy = wd[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') {
+ o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900);
+ }
+ });
+ let botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length > wantBots) {
+ const drop = botIds.pop();
+ if (drop) others.delete(drop);
+ }
+ botIds = [...others.keys()].filter(isPreviewBotId);
+ while (botIds.length < wantBots) {
+ /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */
+ let nextSlot = 0;
+ while (others.has(PREVIEW_BOT_PREFIX + nextSlot)) nextSlot++;
+ previewBotSeq = Math.max(previewBotSeq, nextSlot + 1);
+ const id = PREVIEW_BOT_PREFIX + nextSlot;
+ const joinIdx = countPlayHumans() + botIds.length;
+ let x;
+ let y;
+ if (mapData.gameType === 'jump_survive') {
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
+ x = pos.x;
+ y = pos.y;
+ } else {
+ const sp = pickSpawnForJoinPlay(mapData, joinIdx);
+ const jx = (Math.random() - 0.5) * 0.4;
+ const jy = (Math.random() - 0.5) * 0.4;
+ x = sp.x + 0.5 + jx;
+ y = sp.y + 0.5 + jy;
+ }
+ const tierRoll = Math.random();
+ const stackPrev = mapData && mapData.gameType === 'stack';
+ const botTier = stackPrev
+ ? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
+ : (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
+ const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
+ const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay();
+ others.set(id, {
+ x, y, tx: x, ty: y,
+ direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)],
+ nickname: 'บอท',
+ characterId: pickPreviewBotCharacterId(botIds.length),
+ spawnJoinOrder: joinIdx,
+ playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id),
+ botTier,
+ gauntletJumpTicks: 0,
+ gauntletJumpVis: 0,
+ gauntletScore: crownStart ? 100 : 0,
+ gauntletEliminated: false,
+ quizCannotTrue: false,
+ quizCannotFalse: false,
+ quizCarryHeld: null,
+ botPath: [],
+ botAnswerWander: false,
+ botWanderDx: wd[0],
+ botWanderDy: wd[1],
+ botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: 0,
+ balloonBossScore: 0,
+ balloonBossBossDmg: 0,
+ balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5,
+ balloonBossEliminated: false,
+ botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
+ });
+ botIds.push(id);
+ }
+ let k = 0;
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
+ o.nickname = 'บอท ' + (++k) + ' ' + tag;
+ });
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => {
+ const o = others.get(bid);
+ if (o) o.characterId = pickPreviewBotCharacterId(botIdx);
+ });
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ const t0 = performance.now();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => {
+ const o = others.get(bid);
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140);
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (mapData.gameType === 'stack') {
+ applyStackPreviewSpawnLayout();
+ rebuildStackPreviewTurnOrder();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(false);
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
+ try {
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ }
+
+ function pickRandomWalkableCellInAnswerGrid(grid, o) {
+ if (!grid || !mapData) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = grid[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] !== 1) continue;
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y });
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */
+ function previewBotsPrepareAnswerRound() {
+ if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botPath = [];
+ o.botAnswerWander = false;
+ const tier = o.botTier || 'avg';
+ let pCorrect = 0.52;
+ if (tier === 'sharp') pCorrect = 0.84;
+ else if (tier === 'weak') pCorrect = 0.22;
+ if (Math.random() < 0.14) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const wantsCorrect = Math.random() < pCorrect;
+ const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
+ const zoneGrid = targetTrue ? qt : qf;
+ const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
+ if (!dest) {
+ o.botAnswerWander = true;
+ return;
+ }
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (!path || path.length <= 1) {
+ o.botAnswerWander = true;
+ return;
+ }
+ o.botPath = path.slice(1);
+ });
+ }
+
+ /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
+ function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
+ const w = md.width || 20;
+ const h = md.height || 15;
+ const tGrid = md.quizTrueArea || [];
+ const fGrid = md.quizFalseArea || [];
+ const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
+ const walkable = (x, y) => {
+ if (x < 0 || x >= w || y < 0 || y >= h) return false;
+ const row = md.objects && md.objects[y];
+ return row && row[x] !== 1;
+ };
+ const fx = Math.floor(Number(sx));
+ const fy = Math.floor(Number(sy));
+ if (!walkable(fx, fy)) {
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+ if (!inAnswer(fx, fy)) return { x: sx, y: sy };
+ const q = [[fx, fy]];
+ const seen = new Set([`${fx},${fy}`]);
+ const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
+ while (q.length) {
+ const [cx, cy] = q.shift();
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = cx + dirs[di][0];
+ const ny = cy + dirs[di][1];
+ const k = `${nx},${ny}`;
+ if (seen.has(k)) continue;
+ if (!walkable(nx, ny)) continue;
+ seen.add(k);
+ if (!inAnswer(nx, ny)) {
+ return { x: nx + 0.5, y: ny + 0.5 };
+ }
+ q.push([nx, ny]);
+ }
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
+ }
+
+ function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
+ const tx = Math.floor(ox);
+ const ty = Math.floor(oy);
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ const inT = quizCellOnPlay(qt, tx, ty);
+ const inF = quizCellOnPlay(qf, tx, ty);
+ let choice = null;
+ if (inT && !inF) choice = true;
+ else if (inF && !inT) choice = false;
+ else if (inT && inF) choice = true;
+ const right = choice !== null && choice === correctTrue;
+ return { choice, right };
+ }
+
+ /** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */
+ function quizPreviewJoinOrderForRespawn(actorId) {
+ if (actorId === myId) {
+ const jm = Number(me && me.spawnJoinOrder);
+ return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0;
+ }
+ const o = others.get(actorId);
+ if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) {
+ return Math.max(0, Math.floor(o.spawnJoinOrder));
+ }
+ if (!isPreviewBotId(actorId)) {
+ const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const idx = othersReal.indexOf(actorId);
+ return idx >= 0 ? 1 + idx : 1;
+ }
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const bi = bots.indexOf(actorId);
+ if (bi < 0) return 0;
+ return countPlayHumans() + bi;
+ }
+
+ /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */
+ function resolvePreviewQuizRound() {
+ if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return;
+ const correctTrue = !!previewQuizCurrent.answerTrue;
+ const results = [];
+ const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue);
+ let right = mine.right;
+ let choice = mine.choice;
+ if (mine.right) {
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ playQuizPlayerLocal.cannotTrue = true;
+ playQuizPlayerLocal.cannotFalse = true;
+ if (!warpMeIntoQuizGhostZonePlay()) {
+ const ordMe = quizPreviewJoinOrderForRespawn(myId);
+ const sp = pickSpawnForJoinPlay(mapData, ordMe);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ me.x = pos.x;
+ me.y = pos.y;
+ me.tx = me.x;
+ me.ty = me.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+ results.push({
+ id: myId,
+ right,
+ choice,
+ score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0,
+ });
+ if (playBotsEnabled()) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue);
+ o.botPath = [];
+ o.botAnswerWander = false;
+ if (br.right) {
+ playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
+ } else {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ const ordB = quizPreviewJoinOrderForRespawn(id);
+ const sp = pickSpawnForJoinPlay(mapData, ordB);
+ const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
+ o.x = pos.x + (Math.random() - 0.5) * 0.35;
+ o.y = pos.y + (Math.random() - 0.5) * 0.35;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+ results.push({
+ id,
+ right: br.right,
+ choice: br.choice,
+ score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
+ });
+ });
+ }
+ const r = { results, scores: { ...playLiveQuizScores } };
+ results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) playQuizEverWrong[String(row.id)] = true;
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ }
+
+ function advancePreviewQuizIfDue() {
+ if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return;
+ if (previewQuizStep === 'done') return;
+ if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return;
+ const pool = previewQuizPool;
+ if (!pool || !pool.length) return;
+ if (previewQuizStep === 'read') {
+ applyPreviewAnswerPhase();
+ return;
+ }
+ if (previewQuizStep === 'answer') {
+ resolvePreviewQuizRound();
+ applyPreviewBetweenPhase();
+ return;
+ }
+ if (previewQuizStep === 'between') {
+ previewQuizQIndex += 1;
+ if (previewQuizQIndex >= pool.length) {
+ finishPreviewQuizSessionPlay();
+ return;
+ }
+ const q = pool[previewQuizQIndex];
+ if (q) applyPreviewReadPhase(q, pool.length);
+ }
+ }
+
+ async function loadPreviewQuizAndStart() {
+ resetPreviewBotsQuizState();
+ playQuizEverWrong = {};
+ let settings = null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) settings = await r.json();
+ } catch (e) { /* use map fallback */ }
+ let pool = [];
+ if (settings && Array.isArray(settings.questions) && settings.questions.length) {
+ pool = settings.questions
+ .filter((q) => q && String(q.text || '').trim())
+ .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
+ }
+ if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData);
+ const cap = clampPreviewQuizRoundQuestionCount(settings);
+ const shuffled = shufflePreviewQuizQuestionsPlay(pool);
+ previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length));
+ previewQuizQIndex = 0;
+ const dRead = 10000;
+ const dAns = 5000;
+ const dBet = 3500;
+ previewQuizTiming = {
+ readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000),
+ answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000),
+ betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000),
+ };
+ previewQuizCurrent = null;
+ if (!pool.length) {
+ playQuizPhaseLocal = 'read';
+ previewQuizStep = 'read';
+ playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ';
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ return;
+ }
+ const session = previewQuizPool;
+ const q = session[0];
+ if (q) applyPreviewReadPhase(q, session.length);
+ initPlayLiveQuizScoresZeros();
+ startPlayQuizTimer();
+ }
+
+ function startPlayQuizTimer() {
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(() => {
+ updatePlayQuizTimerDisplay();
+ advancePreviewQuizIfDue();
+ }, 200);
+ updatePlayQuizTimerDisplay();
+ }
+
+ function teardownPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ const mapQText = document.getElementById('quiz-map-question-text');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ clearQuizMapPanelThemeInline(panel, mapQText);
+ }
+ playQuizPhaseLocal = null;
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ previewQuizPool = [];
+ previewQuizQIndex = 0;
+ previewQuizCurrent = null;
+ previewQuizStep = 'read';
+ playLiveQuizScores = {};
+ playQuizEverWrong = {};
+ quizTfScorePopups = [];
+ playPath = [];
+ hidePlayQuizHud();
+ quizCarryJoinSettingsSnap = null;
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ quizCarryMapPanelTheme = null;
+ quizMapPanelTheme = null;
+ quizCarryChoicePlaqueThemes = null;
+ resetQuizCarryPlayState();
+ resetQuizBattlePlayState();
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmTeardown) gcmTeardown.classList.add('is-hidden');
+ cancelEmbedPreviewLobbyReturnTimer();
+ const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn');
+ if (grabBtnTeardown) {
+ grabBtnTeardown.classList.add('is-hidden');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active');
+ grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place');
+ grabBtnTeardown.style.pointerEvents = '';
+ const im = grabBtnTeardown.querySelector('img');
+ if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
+ }
+ const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn');
+ if (gchJumpTeardown) {
+ gchJumpTeardown.classList.add('is-hidden');
+ gchJumpTeardown.setAttribute('aria-hidden', 'true');
+ gchJumpTeardown.style.pointerEvents = '';
+ }
+ const stackDropTeardown = document.getElementById('stack-tower-drop-btn');
+ if (stackDropTeardown) {
+ stackDropTeardown.classList.add('is-hidden');
+ stackDropTeardown.setAttribute('aria-hidden', 'true');
+ stackDropTeardown.style.pointerEvents = '';
+ stackDropTeardown.classList.remove('stack-tower-drop-btn--active');
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function setupPlayQuizUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry())
+ || (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live');
+ if (hideBottomQuizBar) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ const leg = document.getElementById('quiz-play-legend');
+ if (previewMode) {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…';
+ if (leg) leg.textContent = '';
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ';
+ if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/';
+ if (leg) leg.textContent = '';
+ } else {
+ loadPreviewQuizAndStart();
+ }
+ } else {
+ playQuizPhaseLocal = null;
+ playQuizText = 'รอคำถามจากโฮสต์…';
+ playQuizPhaseEndsAt = 0;
+ playQuizQuestionIndex = 0;
+ playQuizQuestionTotal = 0;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)';
+ if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ }
+ if (isQuiz() && !isQuizCarry()) {
+ (async () => {
+ try {
+ let s = {};
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ try {
+ s = JSON.parse(trimmed);
+ } catch (e) { /* ignore */ }
+ }
+ }
+ s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {});
+ setQuizMapPanelThemeFromApi(s);
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e2) { /* ignore */ }
+ } catch (e) { /* ignore */ }
+ })();
+ }
+ }
+
+ /** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */
+ function applyQuizPhaseFromServer(p) {
+ if (!p || !mapData || !isQuiz()) return;
+ if (p.text) playQuizText = p.text;
+ playQuizPhaseLocal = p.phase;
+ playQuizPhaseEndsAt = p.endsAt || 0;
+ playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0);
+ playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0);
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ const qEl = document.getElementById('quiz-game-question');
+ if (phaseEl) {
+ phaseEl.textContent = p.phase === 'read'
+ ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''))
+ : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''));
+ }
+ if (qEl) qEl.textContent = playQuizText || '';
+ const ov = document.getElementById('quiz-game-overlay');
+ if (isQuizQuestionMissionHudActivePlay()) {
+ if (ov) ov.classList.add('is-hidden');
+ } else if (ov) {
+ ov.classList.remove('is-hidden');
+ }
+ if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
+ updatePlayQuizTimerDisplay();
+ if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
+ if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {};
+ try {
+ syncPlayQuizMapPanel();
+ } catch (eQm) { /* ignore */ }
+ }
+
+ function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
+ function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
+ function isGauntletFaceRightMapMno9kb07() {
+ if (!isGauntlet()) return false;
+ /** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */
+ return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID;
+ }
+ /** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */
+ function isGauntletCrownHeistMapPlay() {
+ return isGauntletFaceRightMapMno9kb07();
+ }
+ function isMegaVirusMissionShellMapPlay() {
+ return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID);
+ }
+ /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */
+ function usesCrownLobbyShellPlay() {
+ return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay();
+ }
+ /** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */
+ const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49;
+ /** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */
+ const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1;
+ function isStackTowerEmbedZoomLockedPlay() {
+ return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay());
+ }
+ function applyPlayEmbedZoomForCurrentMapPlay() {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ } else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL;
+ }
+ }
+
+ /** smoothstep 0..1 */
+ function stackTowerPost50SmoothstepPlay(t) {
+ const x = Math.max(0, Math.min(1, t));
+ return x * x * (3 - 2 * x);
+ }
+
+ /** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */
+ function getStackTowerPost50AnimEasePlay() {
+ if (!getStackTowerCameraFollowPlayConfig().enabled) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) {
+ stackTowerPost50AnimStartMs = null;
+ return 0;
+ }
+ if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now();
+ const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS));
+ return stackTowerPost50SmoothstepPlay(u);
+ }
+
+ function getStackTowerPost50ZMulPlay() {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 1;
+ return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e;
+ }
+
+ function getStackTowerPost50BgScrollExtraPxPlay(ch) {
+ const e = getStackTowerPost50AnimEasePlay();
+ if (e <= 0) return 0;
+ const chR = Math.max(1, Number(ch) || 720);
+ return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e;
+ }
+
+ /**
+ * Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul).
+ * Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom.
+ */
+ function computePlayCameraZDrawPlay() {
+ if (!canvas || !mapData) return zoom;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ let zDraw = zoom;
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ /* ซูมออกเล็กน้อยให้เห็นพื้นหลัง/โซน SAFE-SCAM ครบแบบ mock (0.96 เคย crop ขอบล่างบนจอแคบ) */
+ zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.90;
+ }
+ } else if (isQuizCarry()) {
+ const mwPx = w * tileSize;
+ const mhPx = h * tileSize;
+ zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
+ } else if (isQuizBattle()) {
+ zDraw *= quizBattleUserZoomMul;
+ }
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) {
+ zDraw *= playEmbedUserZoomMul;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL;
+ zDraw *= getStackTowerPost50ZMulPlay();
+ }
+ return zDraw;
+ }
+ let gauntletCrownHowtoVisible = false;
+ function isGauntletCrownPregameBlockingPlay() {
+ if (!usesCrownLobbyShellPlay()) return false;
+ if (gauntletCrownRunHeldRemote) return true;
+ if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
+ return false;
+ }
+
+ /**
+ * Last Light (crown heist): center camera on the whole party bbox instead of only `me`
+ * — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible.
+ */
+ function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) {
+ if (!isGauntletCrownHeistMapPlay() || !mapData) return null;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ww = md.width || 20;
+ const hh = md.height || 15;
+ const mapWpx = ww * tileSizeRef;
+ const mapHpx = hh * tileSizeRef;
+ const halfW = canvasW / (2 * zDrawVal);
+ const halfH = canvasH / (2 * zDrawVal);
+ const ents = [];
+ if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) {
+ ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef });
+ }
+ others.forEach((o, id) => {
+ if (!o || o.gauntletEliminated) return;
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return;
+ ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef });
+ });
+ if (!ents.length) return null;
+ let minCx = Infinity;
+ let maxCx = -Infinity;
+ let sumCy = 0;
+ for (let i = 0; i < ents.length; i++) {
+ const e = ents[i];
+ minCx = Math.min(minCx, e.cx);
+ maxCx = Math.max(maxCx, e.cx);
+ sumCy += e.cy;
+ }
+ let px = (minCx + maxCx) * 0.5;
+ let py = sumCy / ents.length;
+ const minCamX = halfW;
+ const maxCamX = Math.max(minCamX, mapWpx - halfW);
+ const minCamY = halfH;
+ const maxCamY = Math.max(minCamY, mapHpx - halfH);
+ const viewW = halfW * 2;
+ const viewH = halfH * 2;
+ /*
+ * ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก)
+ * ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw)
+ */
+ if (viewW >= mapWpx) px = mapWpx * 0.5;
+ else px = Math.max(minCamX, Math.min(maxCamX, px));
+ if (viewH >= mapHpx) py = mapHpx * 0.5;
+ else py = Math.max(minCamY, Math.min(maxCamY, py));
+ return { px, py };
+ }
+
+ /** Preview bots only — full tail-off loss needs server support for real humans */
+ const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40;
+ function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) {
+ if (!playBotsEnabled() || !isGauntletCrownHeistMapPlay() || !mapData) return;
+ if (gauntletCrownPregamePhase !== 'live') return;
+ const halfW = canvas.width / (2 * zDrawVal);
+ const worldMinX = camX - halfW;
+ const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX;
+ const { cw } = getCharacterFootprintWH(mapData);
+ const ts = mapData.tileSize || 32;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return;
+ const rightWorld = (Number(o.x) + cw) * ts;
+ if (rightWorld < gate) o.gauntletEliminated = true;
+ });
+ }
+
+ function isStack() { return mapData && mapData.gameType === 'stack'; }
+ function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
+
+ function currentPlayMapId() {
+ const q = (playSessionMapId || '').trim();
+ if (q) return q;
+ if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim();
+ return (playMapIdFromQuery || '').trim();
+ }
+
+ function isQuizQuestionMissionUiMapPlay() {
+ return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID;
+ }
+
+ function isQuizQuestionMissionPregameBlockingPlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return false;
+ return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionUiMapPlay() {
+ return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID;
+ }
+
+ function getStackTowerBlockWorldScalePlay() {
+ return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ }
+
+ function isStackTowerMissionPregameBlockingPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return false;
+ return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended';
+ }
+
+ function isStackTowerMissionHudActivePlay() {
+ return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ }
+
+ /** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */
+ function isJumpSurviveMissionHudActivePlay() {
+ return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live';
+ }
+
+ /** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */
+ function isSpaceShooterMissionHudActivePlay() {
+ return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live';
+ }
+
+ function stackTowerAssetUrl(file) {
+ return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, '');
+ }
+
+ function ensureStackTowerSlingImagePlay() {
+ if (!stackTowerSlingImg) {
+ stackTowerSlingImg = new Image();
+ stackTowerSlingImg.decoding = 'async';
+ stackTowerSlingImg.src = stackTowerAssetUrl('sling.png');
+ }
+ const im = stackTowerSlingImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerScorePopupImagesPlay() {
+ if (!stackTowerScorePlus10Img) {
+ stackTowerScorePlus10Img = new Image();
+ stackTowerScorePlus10Img.decoding = 'async';
+ stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png');
+ }
+ if (!stackTowerScorePlus20Img) {
+ stackTowerScorePlus20Img = new Image();
+ stackTowerScorePlus20Img.decoding = 'async';
+ stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png');
+ }
+ return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img };
+ }
+
+ function ensureStackTowerPerfectFxImagesPlay() {
+ if (!stackTowerScoreLightImg) {
+ stackTowerScoreLightImg = new Image();
+ stackTowerScoreLightImg.decoding = 'async';
+ stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png');
+ }
+ if (!stackTowerScoreX2Img) {
+ stackTowerScoreX2Img = new Image();
+ stackTowerScoreX2Img.decoding = 'async';
+ stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png');
+ }
+ return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img };
+ }
+
+ /** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */
+ function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) {
+ if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return;
+ const iw = x2Img.naturalWidth;
+ const ih = x2Img.naturalHeight;
+ const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72));
+ const scale = targetH / ih;
+ const dw = iw * scale;
+ const dh = ih * scale;
+ const cx = sx + drawW + drawW * 0.06;
+ const cy = sy + drawH * 0.5;
+ const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1;
+ const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI);
+ if (lightImg && lightImg.complete && lightImg.naturalWidth) {
+ const lp = 2.7;
+ const lw = dw * lp;
+ const lh = dh * lp;
+ const lnx = lightImg.naturalWidth;
+ const lny = lightImg.naturalHeight;
+ /* จัดแสงให้ตรงกลางไอคอน ×2 (ไอคอนวาดเยื้องซ้าย cx - dw*0.48 → กลางไอคอน ≈ cx + dw*0.02) */
+ const iconCx = cx + dw * 0.02;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, pulse + 0.34);
+ ctx.globalCompositeOperation = 'screen';
+ try {
+ ctx.drawImage(lightImg, 0, 0, lnx, lny, iconCx - lw * 0.5, cy - lh * 0.5, lw, lh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.save();
+ ctx.globalAlpha = 0.98;
+ ctx.globalCompositeOperation = 'source-over';
+ try {
+ ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh);
+ } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+
+ function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) {
+ if (!m.scorePopups || !m.scorePopups.length) return;
+ const now = performance.now();
+ const dur = STACK_TOWER_SCORE_POPUP_MS;
+ const imgs = ensureStackTowerScorePopupImagesPlay();
+ m.scorePopups = m.scorePopups.filter((p) => p.until > now);
+ const hidPop = getStackTowerVisualHiddenLayerCountPlay();
+ for (let pi = 0; pi < m.scorePopups.length; pi++) {
+ const p = m.scorePopups[pi];
+ const li = Math.floor(Number(p.layerIndex)) || 0;
+ if (li < 0 || li >= m.layers.length) continue;
+ if (li < hidPop) continue;
+ const L = m.layers[li];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay();
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (li + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const t0 = p.until - dur;
+ const t = Math.max(0, Math.min(1, (now - t0) / dur));
+ const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t);
+ const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom;
+ const im = p.kind === '20' ? imgs.p20 : imgs.p10;
+ const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom));
+ let iw = targetW;
+ let ih = targetW * 0.38;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ ih = (targetW * im.naturalHeight) / im.naturalWidth;
+ }
+ const cx = sx + drawW / 2;
+ const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5;
+ ctx.save();
+ ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha));
+ if (im && im.complete && im.naturalWidth > 0) {
+ try {
+ ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e) { /* ignore */ }
+ } else {
+ ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd';
+ ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy);
+ }
+ ctx.restore();
+ }
+ }
+
+ /** สร้างเศษบล็อกตกจากบล็อกที่พลาด (ใช้ตำแหน่ง/หน้าตาเดิมของ stackFall) */
+ function spawnStackMissDebrisPlay(sf) {
+ if (!sf || !stackMini) return;
+ const lh = stackMini.layerWorldH || Math.max(14, tileSize * 0.3);
+ const cxW = sf.xLeft1 + sf.tw / 2;
+ const landOff = sf.hit && sf.hit.landOffsetTiles ? sf.hit.landOffsetTiles : 0;
+ const dir = landOff >= 0 ? 1 : -1;
+ stackMissDebris.push({
+ cxW,
+ topY: sf.y1,
+ w: sf.tw,
+ h: lh,
+ vx: dir * (tileSize * (1.4 + Math.random() * 0.9)),
+ vy: -tileSize * (0.4 + Math.random() * 0.5),
+ rot: sf.tiltMax || 0,
+ vrot: dir * (2.0 + Math.random() * 1.8),
+ seat: sf.actorSeat,
+ heavy: !!sf.actorHeavy,
+ hue: (stackMini.layers.length * 47) % 360,
+ });
+ while (stackMissDebris.length > 6) stackMissDebris.shift();
+ _stackDebrisLastT = 0;
+ }
+ /** อัปเดต+วาดเศษบล็อกที่ตก (gravity + หมุน + ลอยออกข้าง) จนหลุดขอบจอล่าง */
+ function drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorWorldY) {
+ if (!stackMissDebris.length) return;
+ const now = performance.now();
+ let dt = _stackDebrisLastT ? (now - _stackDebrisLastT) / 1000 : 0.016;
+ _stackDebrisLastT = now;
+ if (dt > 0.05) dt = 0.05;
+ const g = tileSize * 34;
+ const chCv = canvas && canvas.height ? canvas.height : 720;
+ const offLimit = floorWorldY + chCv / Math.max(0.2, zoom) + tileSize * 5;
+ for (let i = stackMissDebris.length - 1; i >= 0; i--) {
+ const d = stackMissDebris[i];
+ d.vy += g * dt;
+ d.topY += d.vy * dt;
+ d.cxW += d.vx * dt;
+ d.rot += d.vrot * dt;
+ if (d.topY > offLimit) { stackMissDebris.splice(i, 1); continue; }
+ const sp = worldToScreen(d.cxW - d.w / 2, d.topY);
+ const drawW = d.w * zoom;
+ const drawH = d.h * zoom;
+ ctx.save();
+ ctx.translate(sp[0] + drawW / 2, sp[1] + drawH / 2);
+ ctx.rotate(d.rot);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, d.seat, d.heavy, d.hue, { missTint: true, missOverlay: true });
+ ctx.restore();
+ }
+ }
+
+ function ensureStackTowerHeartMinusImgPlay() {
+ if (!stackTowerHeartMinusImg) {
+ stackTowerHeartMinusImg = new Image();
+ stackTowerHeartMinusImg.decoding = 'async';
+ stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png');
+ }
+ const im = stackTowerHeartMinusImg;
+ return im && im.complete && im.naturalWidth > 0 ? im : null;
+ }
+
+ function ensureStackTowerLifeHudImagesPlay() {
+ if (!stackTowerLifeBarImg) {
+ stackTowerLifeBarImg = new Image();
+ stackTowerLifeBarImg.decoding = 'async';
+ stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png');
+ }
+ if (!stackTowerLifeHitImg) {
+ stackTowerLifeHitImg = new Image();
+ stackTowerLifeHitImg.decoding = 'async';
+ stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png');
+ }
+ if (!stackTowerLifeFullImg) {
+ stackTowerLifeFullImg = new Image();
+ stackTowerLifeFullImg.decoding = 'async';
+ stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png');
+ }
+ }
+
+ /** Space Shooter (mnpz6rkp): โหลด life HUD จาก /img/ViolentCrime/ แล้วคืน image set ให้ drawStackTowerLifeIntegrityBarPlay */
+ function ensureViolentCrimeLifeHudImagesPlay() {
+ if (!violentCrimeLifeBarImg) {
+ violentCrimeLifeBarImg = new Image();
+ violentCrimeLifeBarImg.decoding = 'async';
+ violentCrimeLifeBarImg.src = violentCrimeAssetUrl('life-bar.png');
+ }
+ if (!violentCrimeLifeHitImg) {
+ violentCrimeLifeHitImg = new Image();
+ violentCrimeLifeHitImg.decoding = 'async';
+ violentCrimeLifeHitImg.src = violentCrimeAssetUrl('life-hit.png');
+ }
+ if (!violentCrimeLifeFullImg) {
+ violentCrimeLifeFullImg = new Image();
+ violentCrimeLifeFullImg.decoding = 'async';
+ violentCrimeLifeFullImg.src = violentCrimeAssetUrl('life-full.png');
+ }
+ return { bar: violentCrimeLifeBarImg, full: violentCrimeLifeFullImg, hit: violentCrimeLifeHitImg };
+ }
+
+ /**
+ * สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG)
+ */
+ const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38;
+
+ /**
+ * วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง
+ */
+ function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem, opts) {
+ const imgs = (opts && opts.images)
+ ? opts.images
+ : (ensureStackTowerLifeHudImagesPlay(), { bar: stackTowerLifeBarImg, full: stackTowerLifeFullImg, hit: stackTowerLifeHitImg });
+ const bar = imgs.bar;
+ const fullIm = (imgs.full && imgs.full.complete && imgs.full.naturalWidth > 0)
+ ? imgs.full
+ : ensureStackTowerHeartMinusImgPlay();
+ const hitIm = imgs.hit && imgs.hit.complete && imgs.hit.naturalWidth > 0
+ ? imgs.hit
+ : null;
+ const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1));
+ const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0));
+
+ if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) {
+ ctx.save();
+ ctx.fillStyle = '#bb9af7';
+ ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ let heartStr = '';
+ for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ ';
+ ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5);
+ ctx.restore();
+ return Math.min(maxH, 28);
+ }
+
+ const bw0 = bar.naturalWidth;
+ const bh0 = bar.naturalHeight;
+ const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC));
+ const scale = Math.min(maxW / bw0, 1.45);
+ const bw = bw0 * scale;
+ const titleDh = titleSrcH * scale;
+ const gap = 3;
+ const bx = destX;
+ const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35);
+ const heartsTop = by0 + titleDh + gap;
+ const roomHearts = Math.max(10, destY + maxH - heartsTop - 2);
+ const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5)));
+
+ ctx.save();
+ try {
+ ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh);
+ } catch (e) { /* ignore */ }
+
+ const cy = heartsTop + heartsH * 0.5;
+ const bracketW = Math.min(14, Math.max(9, bw * 0.07));
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.88)';
+ ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'left';
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.45)';
+ ctx.shadowBlur = 4;
+ ctx.fillText('[', bx + 1, cy);
+ ctx.textAlign = 'right';
+ ctx.fillText(']', bx + bw - 1, cy);
+ ctx.shadowBlur = 0;
+
+ const ix = bx + bracketW + 4;
+ const iwInner = Math.max(24, bw - (bracketW + 4) * 2);
+ const slotW = iwInner / slots;
+ const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34);
+
+ for (let i = 0; i < slots; i++) {
+ const cx = ix + slotW * (i + 0.5);
+ const useFull = i < lives;
+ const im = useFull ? fullIm : hitIm;
+ if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ const nw = im.naturalWidth;
+ const nh = im.naturalHeight;
+ let ih = iconMax;
+ let iw = (ih * nw) / nh;
+ const capW = slotW * 0.82;
+ const capH = heartsH * 0.88;
+ if (iw > capW) {
+ iw = capW;
+ ih = (iw * nh) / nw;
+ }
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ if (iw < 6 && nh > 0) {
+ iw = 6;
+ ih = (iw * nh) / nw;
+ if (ih > capH) {
+ ih = capH;
+ iw = (ih * nw) / nh;
+ }
+ }
+ try {
+ ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih);
+ } catch (e2) { /* ignore */ }
+ } else {
+ ctx.fillStyle = useFull ? '#9ece6a' : '#565f89';
+ ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(useFull ? '♥' : '♡', cx, cy);
+ }
+ }
+ ctx.restore();
+ const usedH = heartsTop + heartsH - by0 + 2;
+ return Math.min(maxH, Math.max(titleDh, usedH));
+ }
+
+ function triggerStackTowerHeartMinusFxPlay() {
+ if (!isStackTowerMissionHudActivePlay()) return;
+ stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS };
+ }
+
+ function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) {
+ if (!stackTowerHeartMinusFx) return;
+ const nt = performance.now();
+ if (nt >= stackTowerHeartMinusFx.until) {
+ stackTowerHeartMinusFx = null;
+ return;
+ }
+ const dur = STACK_TOWER_HEART_MINUS_FX_MS;
+ const t0 = stackTowerHeartMinusFx.until - dur;
+ const u = Math.max(0, Math.min(1, (nt - t0) / dur));
+ const alpha = 1 - u * u;
+ const worldCx = m.topCenterX * tileSize;
+ const stackTopY = floorY - nLay * layerWorldH;
+ const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5;
+ const [sx, sy] = worldToScreen(worldCx, worldCy);
+ const im = ensureStackTowerHeartMinusImgPlay();
+ const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom));
+ let dw = maxW;
+ let dh = maxW * 0.52;
+ if (im && im.naturalWidth > 0 && im.naturalHeight > 0) {
+ dh = (maxW * im.naturalHeight) / im.naturalWidth;
+ }
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha));
+ if (im) {
+ ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('♥ −1', sx, sy);
+ }
+ ctx.restore();
+ }
+
+ /** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */
+ function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) {
+ if (drawW <= 0 || drawH <= 0) return;
+ const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS));
+ const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI);
+ ctx.save();
+ ctx.globalCompositeOperation = 'source-over';
+ /* เติมเหลืองทับบล็อกให้เด่น */
+ ctx.globalAlpha = Math.min(0.6, 0.3 + pulse * 0.32);
+ ctx.fillStyle = 'rgba(255, 224, 60, 0.55)';
+ ctx.fillRect(sx, sy, drawW, drawH);
+ /* กรอบเหลืองหนา "รอบ" บล็อก (เลยขอบออกมา) + เรืองแสงออกนอก → เห็นชัด */
+ const pad = Math.max(3, Math.min(12, drawW * 0.05));
+ const sw = Math.max(3, Math.min(9, drawW * 0.055));
+ ctx.shadowColor = 'rgba(255, 196, 24, 0.98)';
+ ctx.shadowBlur = Math.max(10, Math.min(30, 14 + pulse * 16));
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 0;
+ ctx.globalAlpha = Math.min(1, 0.62 + pulse * 0.38);
+ ctx.strokeStyle = 'rgba(255, 238, 110, 1)';
+ ctx.lineWidth = sw;
+ ctx.strokeRect(sx - pad + sw * 0.5, sy - pad + sw * 0.5, drawW + pad * 2 - sw, drawH + pad * 2 - sw);
+ /* เส้นในขาวอมเหลือง เพิ่มความคม */
+ ctx.shadowBlur = 0;
+ ctx.shadowColor = 'transparent';
+ ctx.globalAlpha = Math.min(0.95, 0.55 + pulse * 0.4);
+ ctx.strokeStyle = 'rgba(255, 252, 214, 0.96)';
+ ctx.lineWidth = Math.max(1.5, sw * 0.42);
+ ctx.strokeRect(sx - pad + sw, sy - pad + sw, drawW + pad * 2 - sw * 2, drawH + pad * 2 - sw * 2);
+ ctx.restore();
+ }
+
+ /**
+ * เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว
+ */
+ function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) {
+ const dx = sx1 - sx0;
+ const dy = sy1 - sy0;
+ const len = Math.hypot(dx, dy);
+ if (len < 1.5) return;
+ const sling = ensureStackTowerSlingImagePlay();
+ if (sling) {
+ const nw = sling.naturalWidth;
+ const nh = sling.naturalHeight;
+ const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2));
+ const ang = Math.atan2(dy, dx);
+ ctx.save();
+ ctx.translate(sx0, sy0);
+ ctx.rotate(ang - Math.PI / 2);
+ ctx.globalAlpha = 0.96;
+ try {
+ ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len);
+ } catch (e) { /* ignore */ }
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ } else {
+ ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)';
+ ctx.lineWidth = Math.max(2.2, zoom * 1.4);
+ ctx.beginPath();
+ ctx.moveTo(sx0, sy0);
+ ctx.lineTo(sx1, sy1);
+ ctx.stroke();
+ ctx.lineWidth = 1;
+ }
+ }
+
+ const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower';
+
+ function setGauntletCrownHowtoStackTowerArtMaskPlay(on) {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!hov) return;
+ hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on);
+ }
+
+ function hideStackTowerHowtoRulesDom() {
+ /* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */
+ }
+
+ function teardownStackTowerMissionUiPlay() {
+ stackTowerMissionPhase = null;
+ stackTowerMissionDeferredPhase = null;
+ stackTowerSessionStartAt = 0;
+ stackTowerMissionEndedOnce = false;
+ stackTowerPost50AnimStartMs = null;
+ hideStackTowerHowtoRulesDom();
+ setGauntletCrownHowtoStackTowerArtMaskPlay(false);
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ }
+
+ function hideStackTowerResultFlashDomOnlyPlay() {
+ if (stackTowerResultFlashTimer) {
+ clearTimeout(stackTowerResultFlashTimer);
+ stackTowerResultFlashTimer = null;
+ }
+ const ov = document.getElementById('stack-tower-result-flash');
+ const img = document.getElementById('stack-tower-result-flash-img');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ if (img) {
+ img.onerror = null;
+ img.removeAttribute('src');
+ }
+ }
+
+ /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */
+ function beginStackTowerResultFlashThenGcm(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outc = String(mission.stackTowerOutcome || 'time_up');
+ let file = 'result-timeup.png';
+ if (outc === 'decrypt_complete') file = 'result-complete.png';
+ else if (outc === 'misses_exhausted') file = 'result-gameover.png';
+ const url = stackTowerAssetUrl(file);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = url;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ /** Jumper mnptfts2: เกรด F = ภาพ gameover · อื่น = complete (ก่อนแผงสรุปผล) */
+ function jumpSurviveMissionOutcomeImageFile(mission) {
+ const g = mission && String(mission.grade || '').toUpperCase();
+ if (g === 'F') return 'result-gameover.png';
+ return 'result-complete.png';
+ }
+
+ /**
+ * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome;
+ * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM
+ */
+ function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'jumper') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission);
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = jumperAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash(outcomeFile, finishToGcm);
+ });
+ } else {
+ showOneFlash(outcomeFile, finishToGcm);
+ }
+ }
+
+ /**
+ * Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM;
+ * ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM
+ */
+ function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!mission || mission.uiSkin !== 'violent_crime') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (file, onDone) {
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ onDone();
+ };
+ imgEl.src = violentCrimeAssetUrl(file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ onDone();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endKind === 'time_up') {
+ showOneFlash('result-timeup.png', function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash('result-gameover.png', finishToGcm);
+ });
+ } else {
+ showOneFlash('result-gameover.png', finishToGcm);
+ }
+ }
+
+ /**
+ * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM
+ * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow)
+ * — ชนะ = end-victory-2.png ใน MegaVirus
+ */
+ function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) {
+ if (!mission || mission.uiSkin !== 'mega_virus') {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!ov || !imgEl) {
+ showGauntletCrownMissionOverlay(mission);
+ return;
+ }
+ const lose = endReason === 'all_dead' || endReason === 'time';
+ const flashSrc = lose ? stackTowerAssetUrl('result-gameover.png') : megaVirusAssetUrl('end-victory-2.png');
+ const finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ imgEl.onerror = function () {
+ imgEl.onerror = null;
+ finishToGcm();
+ };
+ imgEl.src = flashSrc;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () {
+ stackTowerResultFlashTimer = null;
+ finishToGcm();
+ }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ function stackTowerMissionTimeLimitSecPlay() {
+ const t = Number(playStackTowerMissionTimeSec);
+ if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t)));
+ return 90;
+ }
+
+ function stackTowerProgressBlocksPlay() {
+ const n = Number(playStackTowerProgressBlocks);
+ if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n)));
+ return 50;
+ }
+
+ function stackTowerDecryptPctPlay() {
+ if (!stackMini) return 0;
+ if (isStackTowerMissionUiMapPlay()) {
+ const p = Number(stackMini.progressPct);
+ const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0));
+ if (x >= 99.999) return 100;
+ return Math.floor(x);
+ }
+ const teamScore = stackMini.score || 0;
+ return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
+ }
+
+ function stackTowerMissionElapsedSecPlay() {
+ if (stackTowerSessionStartAt <= 0) return 0;
+ return (performance.now() - stackTowerSessionStartAt) / 1000;
+ }
+
+ function applyStackTowerMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = stackTowerAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = stackTowerAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ ensureStackTowerSlingImagePlay();
+ }
+
+ function showStackTowerMissionHowtoOverlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ stackTowerMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ setGauntletCrownHowtoStackTowerArtMaskPlay(true);
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ }
+ }
+
+ /** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */
+ function updateStackTowerMissionHowtoHud() {
+ if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */
+ function updateJumpSurviveMissionHowtoHud() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ /** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */
+ function updateSpaceShooterMissionHowtoHud() {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginStackTowerMissionCountdownThenRun() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+ gauntletCrownHowtoVisible = false;
+ hideStackTowerHowtoRulesDom();
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'live';
+ stackTowerMissionEndedOnce = false;
+ stackTowerSessionStartAt = performance.now();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ stackTowerMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ stackTowerMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function stackTowerMissionBuildPayload(endOpts) {
+ const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete';
+ const rows = [];
+ const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ function pushEnt(id, nickname, characterId, score) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0);
+ }
+ stackPreviewTurnOrder.forEach(function (entry) {
+ if (!entry || entry.kind !== 'bot' || !entry.botId) return;
+ const o = others.get(entry.botId);
+ pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0);
+ });
+ } else {
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ pushEnt(id, o.nickname, o.characterId, teamPts);
+ });
+ }
+ const seen = new Set();
+ const uniq = [];
+ rows.forEach(function (r) {
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ uniq.push(r);
+ });
+ uniq.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = uniq.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && uniq.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: uniq.length,
+ participantCount: uniq.length,
+ stackTowerOutcome: outcome,
+ };
+ }
+
+ function stackTowerMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.stackBotScore) || 0),
+ eliminated: false,
+ hadQuizWrong: false,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.stackBotScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: false,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'stack_tower',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ stackTowerOutcome: mission.stackTowerOutcome,
+ };
+ }
+
+ function stackTowerMissionMaybeEndPlay() {
+ if (!isStackTowerMissionUiMapPlay()) return;
+ if (stackTowerMissionPhase !== 'live') return;
+ if (stackTowerMissionEndedOnce) return;
+ const pct = stackTowerDecryptPctPlay();
+ const elapsed = stackTowerMissionElapsedSecPlay();
+ const timeLim = stackTowerMissionTimeLimitSecPlay();
+ const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0;
+ const timeOut = elapsed >= timeLim;
+ if (pct < 100 && !timeOut && !missesOut) return;
+ stackTowerMissionEndedOnce = true;
+ stackTowerMissionPhase = 'ended';
+ applyStackTowerMissionPanelImages();
+ let outcome = 'time_up';
+ if (pct >= 100) outcome = 'decrypt_complete';
+ else if (missesOut) outcome = 'misses_exhausted';
+ beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome }));
+ }
+
+ function isJumpSurviveMissionUiMapPlay() {
+ return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID;
+ }
+
+ function isJumpSurviveMissionPregameBlockingPlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return false;
+ return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended';
+ }
+
+ function jumperAssetUrl(file) {
+ return BASE + '/img/Jumper/' + file;
+ }
+
+ function hideConflictingOverlaysForJumpSurviveMission() {
+ hideConflictingOverlaysForGauntletCrown();
+ }
+
+ function jumpSurviveTimeLimitSecForMission() {
+ const m = Number(mapData && mapData.jumpSurviveTimeSec);
+ if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
+ const j = Number(playJumpSurviveMissionTimeSec);
+ if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j);
+ return 60;
+ }
+
+ function jumpSurviveRemainingSecMission() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null;
+ const lim = jumpSurviveTimeLimitSecForMission();
+ if (!(lim > 0)) return null;
+ const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function applyJumpSurviveJumperPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = jumperAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = jumperAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showJumpSurviveMissionHowtoOverlay() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ jumpSurviveMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ }
+ }
+
+ function beginJumpSurviveMissionCountdownThenRun() {
+ if (!isJumpSurviveMissionUiMapPlay()) return;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ jumpSurviveMissionPhase = 'live';
+ jumpSurviveGameEnded = false;
+ jumpSurviveEliminated = false;
+ applyJumpSurvivePreviewSpawnLayout(false);
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ jumperMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ jumperMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */
+ function jumpSurviveGradeFromSurvivorCount(survivors) {
+ const s = Math.max(0, Math.floor(Number(survivors) || 0));
+ if (s >= 6) return 'A';
+ if (s === 5) return 'B';
+ if (s === 4) return 'C';
+ if (s === 3) return 'D';
+ if (s === 2) return 'E';
+ return 'F';
+ }
+
+ function jumpSurviveRollRewardCardForGrade(grade) {
+ if (grade === 'F') return null;
+ const r = Math.random();
+ if (grade === 'A' || grade === 'B') {
+ if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C' || grade === 'D') {
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'E') {
+ if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
+ if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ function jumpSurviveMissionTotalParticipants() {
+ let n = myId != null ? 1 : 0;
+ if (others && typeof others.size === 'number') n += others.size;
+ return n;
+ }
+
+ function jumpSurviveMissionCountAlive() {
+ let n = 0;
+ if (myId != null && !jumpSurviveEliminated) n++;
+ if (others && typeof others.forEach === 'function') {
+ others.forEach(function (o) {
+ if (o && !o.jumpSurviveEliminated) n++;
+ });
+ }
+ return n;
+ }
+
+ /** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */
+ function jumpSurviveMissionMaybeEarlyFinish() {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const alive = jumpSurviveMissionCountAlive();
+ if (alive === 0) {
+ endJumpSurviveMissionRound('all_dead');
+ }
+ }
+
+ function jumpSurviveBuildMissionPayload() {
+ const rows = [];
+ const pushEnt = function (id, nickname, characterId, eliminated) {
+ const alive = !eliminated;
+ const baseScore = alive ? 100 : 0;
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: baseScore,
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: baseScore,
+ });
+ };
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < rows.length; si++) {
+ if (rows[si] && !rows[si].eliminated) survivorCount++;
+ }
+ const participantCount = rows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = missionEnsureHumanInTopRanked(rows, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const rewardCard = jumpSurviveRollRewardCardForGrade(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function jumpSurviveMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.jumpSurviveEliminated ? 0 : 100,
+ eliminated: !!o.jumpSurviveEliminated,
+ rankBonus: 0,
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ let survivorCount = 0;
+ for (let si = 0; si < baseRows.length; si++) {
+ if (baseRows[si] && !baseRows[si].eliminated) survivorCount++;
+ }
+ const participantCount = baseRows.length;
+ const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: jumpSurviveRollRewardCardForGrade(grade),
+ totalParts: totalParts,
+ uiSkin: 'jumper',
+ survivorCount: survivorCount,
+ participantCount: participantCount,
+ };
+ }
+
+ function endJumpSurviveMissionRound(endKind) {
+ if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return;
+ jumpSurviveGameEnded = true;
+ jumpSurviveMissionPhase = 'ended';
+ applyJumpSurviveJumperPanelImages();
+ const mission = jumpSurviveBuildMissionPayload();
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+ function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
+
+ function isSpaceShooterMissionUiMapPlay() {
+ return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID;
+ }
+
+ function isSpaceShooterMissionPregameBlockingPlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return false;
+ return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended';
+ }
+
+ function violentCrimeAssetUrl(file) {
+ return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, '');
+ }
+
+ function megaVirusAssetUrl(file) {
+ return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, '');
+ }
+
+ function applyGauntletCrownHeistMissionPanelImages() {
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isGauntletCrownHeistMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ howtoOv.classList.toggle('gch-mg2-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = missionMockHudAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg && isGauntletCrownHeistMapPlay()) {
+ resBg.src = missionMockHudAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applyMegaVirusMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = megaVirusAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = megaVirusAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function applySpaceShooterMissionPanelImages() {
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = violentCrimeAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = violentCrimeAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showSpaceShooterMissionHowtoOverlay() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ spaceShooterMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = humans.length === 1 || isMePlayHost();
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ spaceShooterResetMissionShipStateForAllPlay();
+ }
+
+ function beginSpaceShooterMissionCountdownThenRun() {
+ if (!isSpaceShooterMissionUiMapPlay()) return;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ spaceShooterMissionPhase = 'live';
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ if (myId != null) me.spaceShooterScore = 0;
+ others.forEach(function (o) {
+ if (o) o.spaceShooterScore = 0;
+ });
+ spaceShooterResetMissionShipStateForAllPlay();
+ if (mapData) applySpaceShooterSpawnLayoutPlay();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ spaceShooterMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function spaceShooterBuildMissionPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score, eliminated) {
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: !!eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: rows.length,
+ };
+ }
+
+ function spaceShooterMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ eliminated: !!o.spaceShooterEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.spaceShooterScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'violent_crime',
+ survivorCount: survivorCount,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function balloonBossMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ eliminated: !!o.balloonBossEliminated,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(o.balloonBossScore) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach(function (o) {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ const endR = mission && mission.balloonBossEndReason;
+ if (endR === 'victory' || progress >= 1) grade = 'A';
+ else if (endR === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length,
+ participantCount: baseRows.length,
+ balloonBossEndReason: mission.balloonBossEndReason,
+ };
+ }
+
+ function balloonBossBuildMissionPayloadPlay(reason) {
+ const rows = [];
+ if (myId != null) {
+ rows.push({
+ id: myId,
+ nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'),
+ characterId: me.characterId ?? null,
+ baseScore: Math.max(0, me.balloonBossScore | 0),
+ eliminated: !!me.balloonBossEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ rows.push({
+ id: id,
+ nickname: (o && o.nickname) ? String(o.nickname).trim() : id,
+ characterId: o && o.characterId != null ? o.characterId : null,
+ baseScore: Math.max(0, o.balloonBossScore | 0),
+ eliminated: !!(o && o.balloonBossEliminated),
+ });
+ });
+ rows.sort((a, b) => b.baseScore - a.baseScore
+ || String(a.nickname).localeCompare(String(b.nickname), 'th')
+ || String(a.id).localeCompare(String(b.id)));
+ const top = rows.slice(0, 5);
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map((r) => r.baseScore);
+ const totalSum = totalParts.reduce((s, n) => s + n, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ const maxHp = balloonBossMaxHpPlay();
+ let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => {
+ if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
+ });
+ const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
+ const score100 = Math.min(100, Math.floor(progress * 100));
+ let grade = 'C';
+ if (reason === 'victory' || progress >= 1) grade = 'A';
+ else if (reason === 'all_dead') grade = 'F';
+ else if (score100 >= 50) grade = 'B';
+ else if (totalSum <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'mega_virus',
+ survivorCount: rows.filter((r) => !r.eliminated).length,
+ participantCount: rows.length,
+ balloonBossEndReason: reason,
+ };
+ }
+
+ function endSpaceShooterMissionRound(endKind) {
+ if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return;
+ const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
+ spaceShooterGameEnded = true;
+ spaceShooterMissionPhase = 'ended';
+ applySpaceShooterMissionPanelImages();
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterPopups = [];
+ const mission = spaceShooterBuildMissionPayload();
+ mission.spaceShooterEndKind = kind;
+ beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind);
+ }
+
+ function questionMissionAssetUrl(file) {
+ return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, '');
+ }
+
+ /** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */
+ function questionMissionHudAssetUrl(file) {
+ if (isMissionMockHudPlay()) return missionMockHudAssetUrl(file);
+ return questionMissionAssetUrl(file);
+ }
+
+ /** สกิน HUD แบบ mock Minigame 1–7 (fix 1920×1080 + score-bar / profile) */
+ function getMissionMockHudSkinPlay() {
+ if (isQuizQuestionMissionUiMapPlay()) return 'question_mission';
+ if (isStackTowerMissionUiMapPlay()) return 'stack_tower';
+ if (isJumpSurviveMissionUiMapPlay()) return 'jumper';
+ if (isSpaceShooterMissionUiMapPlay()) return 'violent_crime';
+ if (isMegaVirusMissionShellMapPlay()) return 'mega_virus';
+ if (isQuizCarry() && quizCarryEmbedMissionFlowActive()) return 'quiz_carry';
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return '';
+ }
+
+ function isMissionMockHudPlay() {
+ return !!getMissionMockHudSkinPlay();
+ }
+
+ function missionMockHudAssetUrl(file) {
+ const skin = getMissionMockHudSkinPlay();
+ const f = String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(f);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(f);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(f);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(f);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + f;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + f;
+ return questionMissionAssetUrl(f);
+ }
+
+ function applyMissionMockHudCssVarsPlay(root) {
+ if (!root) return;
+ if (!isMissionMockHudPlay()) {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.removeProperty('--mmh-score-label');
+ root.style.removeProperty('--mmh-score-avatar');
+ root.style.removeProperty('--mmh-mic-on');
+ root.style.removeProperty('--mmh-btn-ready');
+ root.style.removeProperty('--mmh-btn-start');
+ delete root.dataset.missionMockSkin;
+ return;
+ }
+ const skin = getMissionMockHudSkinPlay();
+ root.dataset.missionMockSkin = skin;
+ if (skin === 'gauntlet_crown') {
+ root.style.removeProperty('--mmh-score-bar');
+ root.style.setProperty('--mmh-score-label', "url('" + missionMockHudAssetUrl('score.png') + "')");
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (crownHead && crownImg) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ crownImg.classList.add('mg2-score-label');
+ }
+ } else {
+ root.style.removeProperty('--mmh-score-label');
+ root.style.setProperty('--mmh-score-bar', "url('" + missionMockHudAssetUrl('score-bar.png') + "')");
+ }
+ root.style.setProperty('--mmh-score-avatar', "url('" + missionMockHudAssetUrl('score-avartar.png') + "')");
+ root.style.setProperty('--mmh-mic-on', "url('" + missionMockHudAssetUrl('btn-mic_on.png') + "')");
+ root.style.setProperty('--mmh-btn-ready', "url('" + missionMockHudAssetUrl('btn-ready.png') + "')");
+ root.style.setProperty('--mmh-btn-start', "url('" + missionMockHudAssetUrl('btn-start.png') + "')");
+ const micImg = document.getElementById('play-cyber-mic-img');
+ if (micImg) {
+ micImg.classList.remove('is-hidden');
+ micImg.setAttribute('aria-hidden', 'false');
+ const micSrc = missionMockHudAssetUrl('btn-mic_on.png');
+ if (micImg.getAttribute('src') !== micSrc) micImg.setAttribute('src', micSrc);
+ }
+ const frameOverlay = document.getElementById('play-cyber-profile-frame-overlay');
+ if (frameOverlay) {
+ const frameSrc = missionMockHudAssetUrl('Avartar.png');
+ if (frameOverlay.getAttribute('src') !== frameSrc) frameOverlay.setAttribute('src', frameSrc);
+ }
+ }
+
+ function isQuizQuestionMissionHudActivePlay() {
+ return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
+ }
+
+ /** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
+ function getQuizQuestionMissionMapCenterWorldPxPlay() {
+ if (!mapData || !isQuiz()) return null;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ const ts = tileSize;
+ const mwPx = w * ts;
+ const mhPx = h * ts;
+ return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx };
+ }
+
+ function applyQuizQuestionMissionPanelImages() {
+ /* ติดคลาส scope ให้ overlay howto (popup HOW TO PLAY) ใช้สไตล์ mockup เฉพาะ mng8a80o */
+ const howtoOv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howtoOv) {
+ const tfHowto = isQuizQuestionMissionUiMapPlay();
+ howtoOv.classList.toggle('gch-quiz-tf-mock', tfHowto);
+ if (tfHowto) {
+ ensureQuizTfScaleResizeListener();
+ updateGchTfMockScale(howtoOv);
+ } else {
+ howtoOv.style.removeProperty('--gch-tf-scale');
+ }
+ }
+ const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
+ if (howtoBg) {
+ howtoBg.src = questionMissionAssetUrl('popup-Howto.png');
+ howtoBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
+ };
+ }
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = questionMissionAssetUrl('popup-result.png');
+ resBg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/popup-result.png';
+ };
+ }
+ }
+
+ function showQuizQuestionMissionHowtoOverlay() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) cd.classList.add('is-hidden');
+ quizQuestionMissionPhase = 'howto';
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) st.classList.remove('gch-status--jumper');
+ const btn = document.getElementById('btn-gch-ready');
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ /** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */
+ const soleParticipant = totPlayers === 1;
+ if (soleParticipant) {
+ if (st) {
+ st.textContent = '';
+ st.classList.add('is-hidden');
+ }
+ if (btn) {
+ const canGo = isMePlayHost() || humans.length === 1;
+ btn.classList.remove('is-start-phase');
+ btn.classList.toggle('is-read-only', !canGo);
+ btn.disabled = !canGo;
+ btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
+ btn.setAttribute('aria-pressed', 'false');
+ }
+ } else {
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ }
+ }
+
+ function beginQuizQuestionMissionCountdownThenRun() {
+ if (!isQuizQuestionMissionUiMapPlay()) return;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'countdown';
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) howto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ if (st) {
+ st.classList.add('is-hidden');
+ st.classList.remove('gch-status--jumper');
+ }
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const runFinish = function () {
+ if (cd) cd.classList.add('is-hidden');
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'live';
+ playEmbedUserZoomMul = 1;
+ const qov = document.getElementById('quiz-game-overlay');
+ if (qov) {
+ if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden');
+ else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden');
+ else qov.classList.remove('is-hidden');
+ }
+ const dp = quizQuestionMissionDeferredPhase;
+ quizQuestionMissionDeferredPhase = null;
+ if (dp) applyQuizPhaseFromServer(dp);
+ if (previewMode && isQuiz()) loadPreviewQuizAndStart();
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ let n = 3;
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = function () {
+ n--;
+ if (n > 0) {
+ setCountdown321QuestionAssetGraphic(numEl, n);
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ } else {
+ quizQuestionMissionCountdownTimer = null;
+ runFinish();
+ }
+ };
+ quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
+ }
+
+ function quizQuestionMissionBuildPayload() {
+ const rows = [];
+ function pushEnt(id, nickname, characterId, score) {
+ const sid = String(id);
+ rows.push({
+ id: id,
+ nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
+ characterId: characterId ?? null,
+ baseScore: Math.max(0, Number(score) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(score) || 0),
+ });
+ }
+ if (myId != null) {
+ pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]);
+ }
+ others.forEach(function (o, id) {
+ if (!o) return;
+ pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]);
+ });
+ rows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = rows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: row.baseScore,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && rows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: rows.length,
+ participantCount: rows.length,
+ };
+ }
+
+ function quizQuestionMissionMergePreviewBotsMission(mission) {
+ if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
+ const seen = new Set();
+ (mission.ranked || []).forEach(function (r) {
+ if (r && r.id != null) seen.add(String(r.id));
+ });
+ const baseRows = (mission.ranked || []).map(function (r) {
+ return {
+ id: r.id,
+ nickname: r.nickname,
+ characterId: r.characterId,
+ baseScore: Math.max(0, Number(r.baseScore) || 0),
+ eliminated: false,
+ hadQuizWrong: !!r.hadQuizWrong,
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
+ };
+ });
+ others.forEach(function (o, id) {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ eliminated: false,
+ hadQuizWrong: !!playQuizEverWrong[sid],
+ rankBonus: 0,
+ finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ baseRows.sort(function (a, b) {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 5);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: false,
+ hadQuizWrong: !!row.hadQuizWrong,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0,
+ finalScore: bs,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n2 = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n2);
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (totalSum <= 0 && baseRows.length) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: totalParts,
+ uiSkin: 'question_mission',
+ survivorCount: baseRows.length,
+ participantCount: baseRows.length,
+ };
+ }
+
+ function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
+ function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
+
+ /** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */
+ function quizCarryEmbedMissionFlowActive() {
+ if (!isQuizCarry()) return false;
+ if (previewMode && editorEmbedReturn) return true;
+ return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay);
+ }
+ /** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */
+ function getQuizCarryMapCameraWorldCenterPxPlay() {
+ if (!mapData || mapData.gameType !== 'quiz_carry') return null;
+ const ww = mapData.width || 20, hh = mapData.height || 15;
+ const ts = tileSize;
+ return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 };
+ }
+ function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
+
+ function quizCarryNeonColorForChoice(choiceIndex) {
+ const i = Math.max(0, choiceIndex | 0);
+ const h = (i * 47 + 100) % 360;
+ return 'hsl(' + h + ', 72%, 62%)';
+ }
+
+ function quizCarryMinimapOptionFillCss(ov) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1);
+ if (th.borderMode === 'fixed' && th.fixedBorder) {
+ const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim());
+ if (m) {
+ const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1;
+ const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32;
+ const t = Math.round(aa * 1000) / 1000;
+ return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')';
+ }
+ }
+ const i = Math.max(0, ov - 1);
+ const h = (i * 47 + 100) % 360;
+ return 'hsla(' + h + ', 58%, 52%, 0.32)';
+ }
+
+ function normalizeQuizCarryQuestionFromAny(q) {
+ if (!q) return null;
+ let text = String(q.text || q.question || q.prompt || q.title || '').trim();
+ if (Array.isArray(q.choices) && q.choices.length >= 2) {
+ const choices = q.choices.map((c) => String(c));
+ let ci = Number(q.correctIndex);
+ if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
+ if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
+ let chSlot = Number(q.countdownHighlightSlot);
+ if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null;
+ else chSlot = Math.floor(chSlot);
+ let choiceImageUrls = null;
+ if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
+ const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx]));
+ if (urls.some((u) => u)) choiceImageUrls = urls;
+ }
+ return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls };
+ }
+ if (!text) return null;
+ const t = !!q.answerTrue;
+ return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null };
+ }
+
+ function buildQuizCarryPoolFromMap(md) {
+ if (!md || !Array.isArray(md.quizQuestions)) return [];
+ const out = [];
+ for (let i = 0; i < md.quizQuestions.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
+ if (n) out.push(n);
+ }
+ return out;
+ }
+
+ function getQuizCarryHubTileBounds(md) {
+ if (!md || md.gameType !== 'quiz_carry') return null;
+ const grid = md.quizCarryHubArea;
+ if (!grid || !grid.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let yy = 0; yy < grid.length; yy++) {
+ const row = grid[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (row[xx] === 1) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ }
+ }
+ }
+ if (minX === Infinity) return null;
+ return { minX, minY, maxX, maxY };
+ }
+
+ /** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
+ function getQuizCarryOptionClusterBounds(md, slot1toN) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g.length) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let n = 0, sumX = 0, sumY = 0;
+ for (let yy = 0; yy < g.length; yy++) {
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < row.length; xx++) {
+ if (Number(row[xx]) === slot1toN) {
+ if (xx < minX) minX = xx;
+ if (yy < minY) minY = yy;
+ if (xx > maxX) maxX = xx;
+ if (yy > maxY) maxY = yy;
+ sumX += xx + 0.5;
+ sumY += yy + 0.5;
+ n++;
+ }
+ }
+ }
+ if (!n || minX === Infinity) return null;
+ return {
+ minX, minY, maxX, maxY,
+ cx: sumX / n,
+ cy: sumY / n,
+ };
+ }
+
+ function canvasWordWrapLines(ctx, text, maxWidth) {
+ const raw = String(text || '').trim();
+ if (!raw) return [];
+ const words = raw.split(/\s+/);
+ const lines = [];
+ let cur = words[0];
+ for (let i = 1; i < words.length; i++) {
+ const w = words[i];
+ const test = cur + ' ' + w;
+ if (ctx.measureText(test).width <= maxWidth) cur = test;
+ else {
+ lines.push(cur);
+ cur = w;
+ }
+ }
+ lines.push(cur);
+ return lines;
+ }
+
+ function quizCarryOptionHeldByAnyone(optionIdx) {
+ if (optionIdx == null || optionIdx < 0) return false;
+ if (me.quizCarryHeld === optionIdx) return true;
+ for (const o of others.values()) {
+ if (o && o.quizCarryHeld === optionIdx) return true;
+ }
+ return false;
+ }
+
+ /** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */
+ function spriteOverlapsQuizCarryHubArea(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !sp) return false;
+ const hub = md.quizCarryHubArea;
+ if (!hub || !hub.length) return false;
+ const mw = md.width || 20, mh = md.height || 15;
+ const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = hub[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ if (row[xx] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
+ function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1) return null;
+ let p = preferredIdx | 0;
+ if (p < 0 || p >= n) p = 0;
+ if (!quizCarryOptionHeldByAnyone(p)) return p;
+ const avail = [];
+ for (let i = 0; i < n; i++) {
+ if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
+ }
+ if (!avail.length) return null;
+ return avail[Math.floor(Math.random() * avail.length)];
+ }
+
+ /**
+ * วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl
+ * @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
+ * @param plaqueExtra optional { imageUrl }
+ */
+ function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) {
+ const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex);
+ const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex);
+ const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)';
+ const textCol = th.textColor || '#f8f9ff';
+ const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
+ const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
+ const elImg = plaqueExtra && plaqueExtra.imageElement;
+ const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
+ const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
+ const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
+ const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
+ const simplePreviewPlaque = previewMode && editorEmbedReturn;
+ function pathBoard() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
+ else ctx.rect(signX, signY, w, h);
+ }
+ if (!simplePreviewPlaque) {
+ for (let g = 4; g >= 1; g--) {
+ ctx.strokeStyle = neon;
+ ctx.globalAlpha = 0.07 + g * 0.06;
+ ctx.lineWidth = baseLw + g * 3.2;
+ pathBoard();
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = fillCol;
+ pathBoard();
+ ctx.fill();
+ if (drawPlaqueImg) {
+ ctx.save();
+ pathBoard();
+ ctx.clip();
+ const padImg = Math.max(2, padY * 0.65);
+ const ix = signX + padImg;
+ const iy = signY + padImg;
+ const iw = Math.max(0, w - padImg * 2);
+ const ih = Math.max(0, h - padImg * 2);
+ if (iw > 0 && ih > 0) {
+ const nw = drawPlaqueImg.naturalWidth;
+ const nh = drawPlaqueImg.naturalHeight;
+ const ir = nw / nh;
+ let dw;
+ let dh;
+ if (iw / ih > ir) {
+ dh = ih;
+ dw = dh * ir;
+ } else {
+ dw = iw;
+ dh = dw / ir;
+ }
+ const dx = ix + (iw - dw) / 2;
+ const dy = iy + (ih - dh) / 2;
+ ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
+ }
+ ctx.restore();
+ }
+ ctx.strokeStyle = neon;
+ if (simplePreviewPlaque) {
+ ctx.lineWidth = Math.max(1, baseLw * 0.85);
+ ctx.shadowBlur = 0;
+ pathBoard();
+ ctx.stroke();
+ } else {
+ ctx.lineWidth = baseLw;
+ ctx.shadowColor = neon;
+ ctx.shadowBlur = 14;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 8;
+ pathBoard();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ const attachX = signX + w / 2;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = textCol;
+ const lh = lineH || 12;
+ if (!simplePreviewPlaque && lines && lines.length) {
+ ctx.shadowColor = 'rgba(0,0,0,0.55)';
+ ctx.shadowBlur = 3;
+ }
+ if (lines && lines.length) {
+ const n = lines.length;
+ const textBlockH = n * lh;
+ const y0 = signY + Math.max(padY, (h - textBlockH) / 2);
+ const firstLineCenterY = y0 + lh / 2;
+ for (let li = 0; li < n; li++) {
+ ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh);
+ }
+ }
+ ctx.shadowBlur = 0;
+ if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)';
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.shadowBlur = 0;
+ ctx.beginPath();
+ ctx.moveTo(tether.x0, tether.y0);
+ ctx.lineTo(tether.x1, tether.y1);
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based
+ */
+ function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null;
+ const q = quizCarryCurrent;
+ const choices = q.choices;
+ if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null;
+ const i = idx0Based;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) return null;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ let signX;
+ let signY;
+ let w;
+ let h;
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ signX = sx - minW / 2;
+ signY = sy - minH / 2;
+ w = minW;
+ h = minH;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ const line = choiceText;
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ let maxW = Math.max(56, tileSpanX * ts - 14);
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(maxLineW + padX * 2);
+ h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ signX = sx - w / 2;
+ signY = sy - h / 2;
+ ctx.restore();
+ return { signX, signY, w, h };
+ }
+
+ /**
+ * ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor)
+ * ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด
+ */
+ function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) {
+ if (!isQuizCarry() || !mapData) return;
+ if (mapData.carryEmbedCountdownHighlight === false) return;
+ const optIdx = getQuizCarryLocalGrabbableOptionIndex();
+ if (optIdx == null) return;
+ const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx);
+ if (!pr || pr.w < 4 || pr.h < 4) return;
+ const rx = pr.signX;
+ const ry = pr.signY;
+ const rw = pr.w;
+ const rh = pr.h;
+ const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14));
+ const colRaw = mapData.carryEmbedCountdownHighlightColor;
+ const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a';
+ const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180);
+ ctx.save();
+ function pathRr() {
+ ctx.beginPath();
+ if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr);
+ else ctx.rect(rx, ry, rw, rh);
+ }
+ pathRr();
+ ctx.fillStyle = strokeBase;
+ ctx.globalAlpha = 0.12 * pulse;
+ ctx.fill();
+ for (let g = 6; g >= 1; g--) {
+ ctx.strokeStyle = strokeBase;
+ ctx.globalAlpha = (0.07 + g * 0.055) * pulse;
+ ctx.lineWidth = 2.5 + g * 4;
+ pathRr();
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 0.98;
+ ctx.strokeStyle = strokeBase;
+ ctx.lineWidth = 4;
+ ctx.shadowColor = strokeBase;
+ ctx.shadowBlur = 22 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 12 * pulse;
+ pathRr();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 1;
+ ctx.restore();
+ }
+
+ function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (!quizCarryOptionsPickableNow()) return;
+ const choices = quizCarryCurrent.choices;
+ if (!choices || !choices.length) return;
+ const maxSlots = choices.length;
+ const ts = tileSize * zoom;
+ const ps = quizCarryPlaqueMapScaleClampedPlay();
+ ctx.save();
+ for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
+ if (quizCarryOptionHeldByAnyone(i)) continue;
+ const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
+ if (!b) continue;
+ const choiceText = String(choices[i] || '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
+ const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
+ const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
+ : (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
+ if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
+ const tileSpanX = (b.maxX - b.minX + 1);
+ const tileSpanY = (b.maxY - b.minY + 1);
+ const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
+ const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
+ if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ const lineH = fontPx * 1.2;
+ const padY = 8;
+ const minW = minPlaqueW;
+ const minH = minPlaqueH;
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
+ continue;
+ }
+ const line = choiceText;
+ const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
+ ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ let maxW = Math.max(56, tileSpanX * ts - 14);
+ const lineH = fontPx * 1.2;
+ let textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (!textLines.length) textLines = [line];
+ if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
+ const fp2 = Math.max(9, fontPx * 0.85);
+ ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
+ const lh2 = fp2 * 1.2;
+ maxW = Math.max(48, maxW - 4);
+ textLines = canvasWordWrapLines(ctx, line, maxW);
+ if (textLines.length * lh2 > tileSpanY * ts - 8) {
+ textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(maxLineW + padX * 2);
+ let h = Math.ceil(textLines.length * lh2 + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
+ continue;
+ }
+ for (let ti = 0; ti < textLines.length; ti++) {
+ let s = textLines[ti];
+ if (ctx.measureText(s).width <= maxW) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
+ textLines[ti] = s + '…';
+ }
+ let maxLineW = 0;
+ for (let ti = 0; ti < textLines.length; ti++) {
+ const lw = ctx.measureText(textLines[ti]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const padX = 10;
+ const padY = 8;
+ let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
+ let h = Math.ceil(textLines.length * lineH + padY * 2);
+ if (imgUrl || plaqueExtra.imageElement) {
+ w = Math.max(w, minPlaqueW);
+ h = Math.max(h, minPlaqueH);
+ }
+ const wx = b.cx * tileSize;
+ const wy = b.cy * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const signX = sx - w / 2;
+ const signY = sy - h / 2;
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
+ }
+ ctx.restore();
+ }
+
+ function quizCarryHubCellPlay(md, tx, ty) {
+ const g = md && md.quizCarryHubArea;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryInteractiveCellPlay(md, tx, ty) {
+ const g = md && md.interactive;
+ return !!(g && g[ty] && g[ty][tx] === 1);
+ }
+
+ function quizCarryMapHasAnswerInteractive(md) {
+ if (!md || !md.interactive || !md.interactive.length) return false;
+ const h = md.height || 15, w = md.width || 20;
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ /** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
+ function quizCarryOptionIndexAtPlay(md, tx, ty) {
+ const g = md && md.quizCarryOptionArea;
+ if (!g || !g[ty]) return null;
+ const v = g[ty][tx];
+ if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
+ const idx = v - 1;
+ const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
+ if (n < 1 || idx >= n) return null;
+ return idx;
+ }
+
+ /** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
+ function quizCarryFootprintOverlapsHub(px, py) {
+ if (!mapData || !isQuizCarry()) return false;
+ const md = mapData;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ /** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
+ function quizCarryFootprintAdjacentToHub(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryHubCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryHubCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtHub(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
+ }
+
+ function quizCarryFootprintOverlapsInteractive(md, px, py) {
+ if (!md) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
+ }
+ return false;
+ }
+
+ function quizCarryFootprintAdjacentToInteractive(md, px, py) {
+ if (!md) return false;
+ const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
+ if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
+ }
+ }
+ return false;
+ }
+
+ function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
+ if (!md) return false;
+ return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
+ }
+
+ /** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
+ function quizCarryCanSubmitAnswerAt(md, px, py) {
+ if (!md) return false;
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ return quizCarryCanSubmitAtInteractiveZone(md, px, py);
+ }
+ return quizCarryCanSubmitAtHub(md, px, py);
+ }
+
+ /** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
+ function formatQuizCarryQuestionHud(q) {
+ if (!q) return '';
+ return String(q.text || q.question || q.prompt || q.title || '').trim();
+ }
+
+ function quizCarryRestoreEmbedPhaseLabel() {
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl || !mapData) return;
+ phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
+ ? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
+ : 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
+ }
+
+ function isQuizCarryEmbedCountdownBlockingMovement() {
+ if (!quizCarryEmbedMissionFlowActive()) return false;
+ const now = Date.now();
+ return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now);
+ }
+
+ function isQuizCarryEmbedLobbyBlockingMovement() {
+ return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive);
+ }
+
+ function quizCarryPregameHumanIds() {
+ const ids = [];
+ if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId));
+ others.forEach((_, id) => {
+ if (!isPreviewBotId(id)) ids.push(String(id));
+ });
+ return ids;
+ }
+
+ function quizCarryPregameBotCount() {
+ let n = 0;
+ others.forEach((_, id) => {
+ if (isPreviewBotId(id)) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (quizCarryLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function quizCarryPregameTotalPlayers() {
+ return quizCarryPregameHumanIds().length + quizCarryPregameBotCount();
+ }
+
+ function isMePlayHost() {
+ if (myId == null) return false;
+ if (playHostId == null) return true;
+ return String(playHostId) === String(myId);
+ }
+
+ function hideQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ }
+
+ function showQuizCarryPregameOverlay() {
+ const ov = document.getElementById('quiz-carry-pregame-overlay');
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ updateQuizCarryPregameHud();
+ }
+
+ function quizCarrySyncGuestReadyIfNeeded() {
+ if (!quizCarryPregameActive || myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (quizCarryLobbyReadyMap[sid]) return;
+ quizCarryLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true });
+ }
+
+ function updateQuizCarryPregameHud() {
+ if (!quizCarryPregameActive) return;
+ const st = document.getElementById('quiz-carry-pregame-status');
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
+ const num = quizCarryPregameReadyNumerator();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ if (st) {
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ }
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
+ if (primaryImg) {
+ primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
+ primaryImg.alt = humansReady ? 'START' : 'READY';
+ }
+ if (primary) {
+ primary.classList.toggle('is-start-phase', humansReady);
+ primary.classList.toggle('is-read-only', !isMePlayHost());
+ primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady);
+ primary.disabled = !isMePlayHost();
+ primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false'));
+ primary.title = isMePlayHost()
+ ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+ }
+
+ function beginQuizCarryEmbedPregame() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ quizCarryPregameActive = true;
+ quizCarryLobbyReadyMap = {};
+ quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; });
+ showQuizCarryPregameOverlay();
+ if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request');
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+
+ function endQuizCarryEmbedPregameAndStart() {
+ if (!quizCarryPregameActive) return;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPickNextQuestion();
+ const nowEmb = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowEmb) && !(quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ }
+
+ function quizCarryOptionsPickableNow() {
+ if (!isQuizCarry() || !quizCarryCurrent) return true;
+ /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return false;
+ if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
+ return Date.now() >= quizCarryOptionRevealAt;
+ }
+
+ function quizCarryApplyPhaseTimersForCurrentQuestion() {
+ if (!quizCarryCurrent) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ return;
+ }
+ const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ if (readMs <= 0) {
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ } else {
+ quizCarryOptionRevealAt = t0 + readMs;
+ quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
+ }
+ }
+
+ /** embed: หลัง 3–2–1 ก่อนหยิบ — เปิดป้ายตัวเลือกทันที + จับเวลาตอบ carryAnswerMs */
+ function quizCarryApplyEmbedAnswerWindowAfterPreOption321() {
+ if (!quizCarryCurrent) return;
+ const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
+ const t0 = Date.now();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = t0 + ansMs;
+ }
+
+ function updateQuizCarryCarryPhaseHud() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !quizCarryCurrent) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) return;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (!phaseEl) return;
+ if (quizCarryAnswerTimeupAwaitNext && quizCarryAnswerRoundTimeupAdvanceAt > 0) {
+ const sec = Math.max(0, Math.ceil((quizCarryAnswerRoundTimeupAdvanceAt - Date.now()) / 1000));
+ phaseEl.textContent = 'หมดเวลาตอบ — ไปข้อถัดไปใน ' + sec + ' วิ…';
+ return;
+ }
+ const now = Date.now();
+ if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > 0 && now < quizCarryEmbedPreOptionCountdownEndAt) {
+ const sec = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - now) / 1000));
+ phaseEl.textContent = 'เตรียมหยิบคำตอบ — นับ 3·2·1 (' + sec + ' วิ)';
+ return;
+ }
+ if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
+ const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
+ phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
+ return;
+ }
+ if (quizCarryAnswerCloseAt > now) {
+ const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
+ phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
+ return;
+ }
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+
+ function tickQuizCarryRoundTimers() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
+ if (quizCarryEmbedCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return;
+ if (quizCarryEmbedPendingQuestion) return;
+ if (quizCarryAnswerTimeupAwaitNext) return;
+ if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
+ if (Date.now() < quizCarryAnswerCloseAt) return;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryOptionRevealAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ if (isDetectiveMinigamePlay()) {
+ quizCarryAfterRoundResolved();
+ return;
+ }
+ quizCarryAnswerTimeupAwaitNext = true;
+ showQuizCarryAnswerRoundTimeupOverlay();
+ updateQuizCarryCarryPhaseHud();
+ }
+
+ function resetQuizCarryEmbedCountdownOverlayInners() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = 'คำถาม · Question';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ /** นับ 3–2–1: ซ่อนคำถามและหัวข้อ — เหลือแค่รูปเลขจาก /Game/img/QUESTION */
+ function showQuizCarryEmbedCountdownDigitsOnlyChrome() {
+ const cq = document.getElementById('quiz-carry-embed-countdown-q');
+ const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
+ if (cq) {
+ cq.textContent = '';
+ cq.style.display = 'none';
+ }
+ if (ck) {
+ ck.textContent = '';
+ ck.style.display = 'none';
+ ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
+ }
+ }
+
+ function hideQuizCarryEmbedCountdownOverlay() {
+ resetQuizCarryEmbedCountdownLayoutDom();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ resetQuizCarryEmbedCountdownOverlayInners();
+ }
+
+ function tickQuizCarryEmbedCountdown() {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ const now = Date.now();
+ const ov = document.getElementById('quiz-carry-embed-countdown');
+ const numEl = document.getElementById('quiz-carry-embed-countdown-num');
+
+ if (quizCarryCurrent && quizCarryEmbedPreOptionCountdownEndAt > 0) {
+ if (!numEl) return;
+ if (now >= quizCarryEmbedPreOptionCountdownEndAt) {
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ quizCarryApplyEmbedAnswerWindowAfterPreOption321();
+ quizCarryRestoreEmbedPhaseLabel();
+ syncQuizCarryEmbedQuestionStrip();
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsedPre = now - quizCarryEmbedPreOptionCountdownStartAt;
+ const nPre = 3 - Math.min(2, Math.floor(elapsedPre / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, nPre)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+
+ if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
+ if (now >= quizCarryEmbedCountdownEndAt) {
+ const pq = quizCarryEmbedPendingQuestion;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ if (!pq) return;
+ quizCarryCurrent = pq;
+ preloadQuizCarryChoiceImages(pq);
+ playQuizText = formatQuizCarryQuestionHud(pq);
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ quizCarryRestoreEmbedPhaseLabel();
+ others.forEach((o) => {
+ if (!o) return;
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ syncQuizCarryEmbedQuestionStrip();
+ quizCarryEmbedPreOptionCountdownStartAt = Date.now();
+ quizCarryEmbedPreOptionCountdownEndAt = quizCarryEmbedPreOptionCountdownStartAt + 3000;
+ updateQuizCarryCarryPhaseHud();
+ return;
+ }
+ const elapsed = now - quizCarryEmbedCountdownStartAt;
+ const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
+ setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, n)));
+ if (ov) {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ syncQuizCarryEmbedCountdownLayout();
+ showQuizCarryEmbedCountdownDigitsOnlyChrome();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+
+ function quizCarryPickNextQuestion() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!quizCarryPool.length) {
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
+ return;
+ }
+ let q;
+ let guard = 0;
+ do {
+ q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
+ guard++;
+ } while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
+ if (quizCarryEmbedMissionFlowActive()) {
+ quizCarryEmbedPendingQuestion = q;
+ preloadQuizCarryChoiceImages(q);
+ quizCarryEmbedCountdownStartAt = Date.now();
+ quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryCurrent = null;
+ playQuizText = '';
+ clearQuizMapQuestionCarryFeedback();
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ /* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
+ o.botQuizCarryPathfindAfter = 0;
+ });
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = '…';
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
+ tickQuizCarryEmbedCountdown();
+ syncQuizCarryEmbedQuestionStrip();
+ return;
+ }
+ quizCarryCurrent = q;
+ preloadQuizCarryChoiceImages(q);
+ playQuizText = formatQuizCarryQuestionHud(q);
+ clearQuizMapQuestionCarryFeedback();
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ });
+ quizCarryApplyPhaseTimersForCurrentQuestion();
+ }
+
+ /** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */
+ function quizCarryAfterRoundResolved() {
+ if (!isQuizCarry()) return;
+ if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) {
+ finishDetectiveMinigameAndReturnLobby();
+ return;
+ }
+ if (!quizCarryEmbedMissionFlowActive() && !previewMode) {
+ quizCarryPickNextQuestion();
+ return;
+ }
+ quizCarryRoundsCompleted++;
+ const lim = quizCarrySessionLength;
+ if (lim > 0 && quizCarryRoundsCompleted >= lim) {
+ showQuizCarrySessionCompleteOverlay();
+ return;
+ }
+ quizCarryPickNextQuestion();
+ }
+
+ function playQuizCarryAvatarUrlForMission(characterId) {
+ if (!characterId) return '';
+ return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
+ }
+
+ /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */
+ function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) {
+ if (!imgEl || !characterId) return;
+ let attempts = 0;
+ const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId);
+ const tick = () => {
+ attempts++;
+ const nowT = Date.now();
+ const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down');
+ if (!rawImg) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) {
+ if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts);
+ setTimeout(tick, 90);
+ return;
+ }
+ if (!rawImg.naturalWidth) {
+ if (fallbackUrl) imgEl.src = fallbackUrl;
+ return;
+ }
+ const tint = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local'));
+ const isMePeer = (peerId != null && myId != null && String(peerId) === String(myId));
+ const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer);
+ if (out && out.tagName === 'CANVAS' && out.width > 0) {
+ try {
+ imgEl.src = out.toDataURL('image/png');
+ return;
+ } catch (e) { /* ต่อไปลอง walk หรือ URL */ }
+ }
+ if (out && out.src && out !== rawImg) {
+ imgEl.src = out.src;
+ return;
+ }
+ if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) {
+ setTimeout(tick, 120);
+ return;
+ }
+ imgEl.src = rawImg.src || fallbackUrl;
+ };
+ tick();
+ }
+
+ /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */
+ function quizCarryGradeFromTeamAverage(avgRounded) {
+ if (avgRounded >= 80) return 'A';
+ if (avgRounded >= 60) return 'B';
+ return 'C';
+ }
+
+ function quizCarryRollCardRewardForGrade(grade) {
+ const u = Math.random() * 100;
+ if (grade === 'A') {
+ if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
+ if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'C') {
+ if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' };
+ return { th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ return null;
+ }
+
+ function quizCarryBuildMissionRankList() {
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ characterId: me.characterId || getPlayCharacterId(),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ characterId: o.characterId || null,
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ return ranks.slice(0, 5);
+ }
+
+ /** payload สำหรับ overlay สรุปผลกลาง (gauntlet-crown-mission-overlay) — ให้ MG4 ใช้หน้าจบแบบเดียวกับเกมอื่น */
+ function quizCarryBuildGcmMissionPayload(opts) {
+ opts = opts || {};
+ const ranks = quizCarryBuildMissionRankList();
+ const ranked = ranks.map(function (row, idx) {
+ const pos = idx + 1;
+ const sc = Math.max(0, Number(row.score) || 0);
+ return {
+ id: row.id, nickname: row.nickname, characterId: row.characterId,
+ baseScore: sc, eliminated: false,
+ rank: pos, rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus: 0, finalScore: sc,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.baseScore; });
+ const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.min(100, Math.max(0, Math.round(totalSum / n)));
+ const grade = opts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(averageScore);
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked, totalSum: totalSum, averageScore: averageScore,
+ grade: grade, rewardCard: rewardCard, totalParts: totalParts,
+ uiSkin: 'quiz_carry', survivorCount: opts.forceGradeF ? 0 : ranked.length,
+ participantCount: ranked.length,
+ };
+ }
+
+ /** ตั้งรูปพื้นหลังหน้าจบของ overlay กลางเป็นชุด quiz-carry (cut) */
+ function applyQuizCarryGcmMissionBg() {
+ const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
+ if (resBg) {
+ resBg.src = BASE + '/img/quiz-carry/popup-result.png';
+ resBg.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/popup-result.png'; };
+ }
+ }
+
+ function cancelEmbedPreviewLobbyReturnTimer() {
+ if (embedPreviewLobbyReturnTimer != null) {
+ clearTimeout(embedPreviewLobbyReturnTimer);
+ embedPreviewLobbyReturnTimer = null;
+ }
+ }
+
+ function cancelQuizCarryResultEndAfterTimeup() {
+ if (quizCarryResultEndTimer != null) {
+ clearTimeout(quizCarryResultEndTimer);
+ quizCarryResultEndTimer = null;
+ }
+ cancelEmbedPreviewLobbyReturnTimer();
+ }
+
+ function cancelQuizCarrySessionCompleteResultToSummary() {
+ if (quizCarrySessionCompleteResultToSummaryT != null) {
+ clearTimeout(quizCarrySessionCompleteResultToSummaryT);
+ quizCarrySessionCompleteResultToSummaryT = null;
+ }
+ }
+
+ function cancelQuizCarryAnswerRoundTimeupAuto() {
+ if (quizCarryAnswerRoundTimeupAutoT != null) {
+ clearTimeout(quizCarryAnswerRoundTimeupAutoT);
+ quizCarryAnswerRoundTimeupAutoT = null;
+ }
+ quizCarryAnswerRoundTimeupAdvanceAt = 0;
+ }
+
+ function quizCarrySetAnswerRoundTimeupVisualActive(on) {
+ try {
+ if (on) document.documentElement.classList.add('quiz-carry-answer-timeup-active');
+ else document.documentElement.classList.remove('quiz-carry-answer-timeup-active');
+ } catch (e) { /* ignore */ }
+ }
+
+ /** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */
+ function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ if (!(previewMode && editorEmbedReturn)) return;
+ embedPreviewLobbyReturnTimer = setTimeout(function () {
+ embedPreviewLobbyReturnTimer = null;
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }, 5000);
+ }
+
+ /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */
+ function quizCarryAnyPlayerHasCorrectAnswer() {
+ if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false;
+ const keys = Object.keys(playLiveQuizScores);
+ for (let i = 0; i < keys.length; i++) {
+ if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true;
+ }
+ return false;
+ }
+
+ function hideQuizCarryResultEndLayer() {
+ cancelEmbedPreviewLobbyReturnTimer();
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ function showQuizCarryResultEndLayer(useComplete) {
+ const el = document.getElementById('quiz-carry-result-end-layer');
+ const img = document.getElementById('quiz-carry-result-end-img');
+ if (!el || !img) return;
+ const fname = useComplete ? 'result-complete.png' : 'result-gameover.png';
+ if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl(fname) + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ }
+
+ /** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */
+ function scheduleEmbedDeskResultAfterDelay(okResolver) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryResultEndLayer();
+ const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; };
+ quizCarryResultEndTimer = setTimeout(function () {
+ quizCarryResultEndTimer = null;
+ let anyOk = false;
+ try {
+ anyOk = !!resolve();
+ } catch (e) {
+ anyOk = false;
+ }
+ hideQuizCarryTimeupOnDeskLayer();
+ showQuizCarryResultEndLayer(anyOk);
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ }, 5000);
+ }
+
+ function hideQuizCarryTimeupOnDeskLayer() {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ cancelQuizCarryResultEndAfterTimeup();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) {
+ quizCarryAnswerTimeupAwaitNext = false;
+ return;
+ }
+ el.classList.remove('qc-timeup-desk--interactive');
+ quizCarryAnswerTimeupAwaitNext = false;
+ const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner');
+ if (anchor) {
+ anchor.style.left = '';
+ anchor.style.top = '';
+ anchor.style.width = '';
+ anchor.style.height = '';
+ }
+ el.classList.add('is-hidden');
+ el.setAttribute('aria-hidden', 'true');
+ }
+
+ /** หมดเวลาตอบ (carryAnswerMs) — โชว์ TIME'S UP บนโซนคำถาม ~3s แล้วไปข้อถัดไป (ไม่ใช่ flow 5s → result ของ embed จบภารกิจ) */
+ function showQuizCarryAnswerRoundTimeupOverlay() {
+ if (!isQuizCarry()) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ quizCarrySetAnswerRoundTimeupVisualActive(true);
+ quizCarryAnswerRoundTimeupAdvanceAt = Date.now() + 3000;
+ el.classList.add('qc-timeup-desk--interactive');
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ quizCarryAnswerRoundTimeupAutoT = setTimeout(function () {
+ quizCarryAnswerRoundTimeupAutoT = null;
+ dismissQuizCarryAnswerRoundTimeupAndContinue();
+ }, 3000);
+ }
+
+ function dismissQuizCarryAnswerRoundTimeupAndContinue() {
+ if (!quizCarryAnswerTimeupAwaitNext) return;
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarryAnswerTimeupAwaitNext = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ quizCarryAfterRoundResolved();
+ }
+
+ /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน
+ * @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */
+ function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) {
+ cancelQuizCarryAnswerRoundTimeupAuto();
+ quizCarrySetAnswerRoundTimeupVisualActive(false);
+ const el = document.getElementById('quiz-carry-timeup-desk-layer');
+ if (!el) return;
+ // Last Light (Gauntlet Crown): skip TIME'S UP desk graphic; keep embed 5s → result flow.
+ if (isGauntletCrownHeistMapPlay()) {
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(
+ typeof embedResultOkFn === 'function' ? embedResultOkFn : quizCarryAnyPlayerHasCorrectAnswer
+ );
+ }
+ return;
+ }
+ const img = document.getElementById('quiz-carry-timeup-txt-img');
+ if (img) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else if (isSpaceShooterMissionUiMapPlay()) {
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ };
+ img.src = violentCrimeAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
+ } else {
+ img.onerror = null;
+ img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
+ }
+ }
+ el.classList.remove('is-hidden');
+ el.setAttribute('aria-hidden', 'false');
+ try {
+ syncQuizCarryTimeupDeskLayerPosition();
+ } catch (e) { /* ignore */ }
+ if (previewMode && editorEmbedReturn) {
+ scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer);
+ }
+ }
+
+ function showQuizCarryMissionSummaryOverlay(opts) {
+ opts = opts || {};
+ const forceF = !!opts.forceGradeF;
+ const overlay = document.getElementById('quiz-carry-mission-overlay');
+ if (!overlay) return;
+ hideQuizCarryTimeupOnDeskLayer();
+ const ranks = quizCarryBuildMissionRankList();
+ const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0));
+ const total = parts.reduce((a, b) => a + b, 0);
+ const n = ranks.length;
+ const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0;
+ const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade);
+ const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade);
+
+ const rowEl = document.getElementById('qc-mission-rank-row');
+ const totalEl = document.getElementById('qc-mission-total-line');
+ const gradeEl = document.getElementById('qc-mission-grade');
+ const cardTextEl = document.getElementById('qc-mission-card-text');
+ const checkEl = document.getElementById('qc-mission-check');
+ const successTxt = document.getElementById('qc-mission-success-txt');
+ const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]'];
+ if (rowEl) {
+ rowEl.innerHTML = '';
+ ranks.forEach((r, idx) => {
+ const rank = idx + 1;
+ const cell = document.createElement('div');
+ cell.className = 'qc-mission-rank-cell';
+ const tag = document.createElement('div');
+ tag.className = 'qc-mission-rank-tag';
+ tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']');
+ const frame = document.createElement('div');
+ frame.className = 'qc-mission-rank-frame';
+ const img = document.createElement('img');
+ img.className = 'qc-mission-rank-avatar';
+ img.alt = '';
+ applyQuizCarryRankAvatarTint(img, r.characterId, r.id);
+ img.onerror = function () {
+ this.onerror = null;
+ this.src = 'data:image/svg+xml,' + encodeURIComponent('? ');
+ };
+ frame.appendChild(img);
+ const nick = document.createElement('div');
+ nick.className = 'qc-mission-rank-nick';
+ nick.textContent = r.nickname;
+ nick.title = r.nickname;
+ const sc = document.createElement('div');
+ sc.className = 'qc-mission-rank-score';
+ sc.textContent = String(r.score);
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ }
+ if (totalEl) {
+ totalEl.textContent = '';
+ if (!n) {
+ totalEl.textContent = '—';
+ } else {
+ const lab = document.createElement('span');
+ lab.textContent = 'คะแนนรวม ';
+ const nums = document.createElement('span');
+ nums.className = 'qc-mission-total-nums';
+ nums.textContent = '(' + parts.join('+') + ') = ' + total;
+ const tail = document.createElement('span');
+ tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100';
+ totalEl.appendChild(lab);
+ totalEl.appendChild(nums);
+ totalEl.appendChild(tail);
+ }
+ }
+ if (gradeEl) {
+ gradeEl.textContent = grade;
+ gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase();
+ }
+ const okMission = grade !== 'F';
+ if (checkEl) checkEl.style.display = okMission ? '' : 'none';
+ if (successTxt) {
+ successTxt.style.display = okMission ? '' : 'none';
+ if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ';
+ }
+ if (cardTextEl) {
+ cardTextEl.textContent = '';
+ if (grade === 'F') {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = 'ไม่ได้รับการ์ด';
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = 'เกรด F';
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ } else if (reward) {
+ const pl = document.createElement('div');
+ pl.className = 'qc-mission-reward-plaque';
+ const t = document.createElement('div');
+ t.className = 'qc-mission-reward-plaque-title';
+ t.textContent = reward.th;
+ const s = document.createElement('div');
+ s.className = 'qc-mission-reward-plaque-sub';
+ s.textContent = reward.en;
+ pl.appendChild(t);
+ pl.appendChild(s);
+ cardTextEl.appendChild(pl);
+ }
+ /* การ์ดพิเศษจากชุดคำถาม (ได้มาจากการตอบคำถามพิเศษในฉาก) — แสดงเสมอถ้าได้รับ */
+ if (specialQuizAwardedCard) renderSpecialCardInBonus(cardTextEl, specialQuizAwardedCard);
+ }
+ overlay.classList.remove('is-hidden');
+ const btn = document.getElementById('btn-quiz-carry-mission-done');
+ if (btn) {
+ btn.onclick = function () {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ overlay.classList.add('is-hidden');
+ if (isDetectiveMinigamePlay()) {
+ finishDetectiveMinigameAndReturnLobby();
+ } else {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ };
+ }
+ }
+
+ function showQuizCarrySessionCompleteOverlay() {
+ if (quizCarrySessionEnded) return;
+ quizCarrySessionEnded = true;
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ hideQuizCarryEmbedCountdownOverlay();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ playQuizText = 'ครบชุดคำถามแล้ว';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
+ const missionOv = document.getElementById('quiz-carry-mission-overlay');
+ if (missionOv && quizCarryUseMissionSummaryOverlay) {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ hideQuizCarryResultEndLayer();
+ const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) };
+ const splashRanks = quizCarryBuildMissionRankList();
+ const splashParts = splashRanks.map((r) => Math.max(0, Number(r.score) || 0));
+ const splashTotal = splashParts.reduce((a, b) => a + b, 0);
+ const splashN = splashRanks.length;
+ const splashAvg = splashN ? Math.min(100, Math.max(0, Math.round(splashTotal / splashN))) : 0;
+ const splashGrade = summaryOpts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(splashAvg);
+ const useCompleteSplash = splashGrade !== 'F';
+ showQuizCarryResultEndLayer(useCompleteSplash);
+ quizCarrySessionCompleteResultToSummaryT = setTimeout(function () {
+ quizCarrySessionCompleteResultToSummaryT = null;
+ hideQuizCarryResultEndLayer();
+ /* ใช้หน้าจบ overlay กลาง (เหมือนเกมอื่น) skin quiz_carry แทน overlay เฉพาะเดิม */
+ showGauntletCrownMissionOverlay(quizCarryBuildGcmMissionPayload(summaryOpts));
+ }, QUIZ_CARRY_SESSION_END_SPLASH_MS);
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'เกมจบ · Game over';
+ msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o) return;
+ ranks.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
+ });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+
+ /** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */
+ function showQuizCarryToast(msg, ok) {
+ if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
+ window.__quizCarryToastT = null;
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.classList.add('is-hidden');
+ el.className = 'play-quiz-feedback';
+ el.textContent = '';
+ }
+
+ /**
+ * หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
+ * @returns {boolean} ทำ action สำเร็จหรือไม่
+ */
+ function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
+ opts = opts || {};
+ if (quizCarryPregameActive) return false;
+ if (quizCarrySessionEnded) return false;
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(ox, oy);
+ const ent = actorId === myId ? me : others.get(actorId);
+ if (!ent) return false;
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
+ const correct = quizCarryCurrent.correctIndex;
+ let held = ent.quizCarryHeld;
+ if (held != null && canSubmitAnswer) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ ent.quizCarryHeld = null;
+ const ok = held === correct;
+ if (ok) {
+ playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
+ const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
+ const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
+ const showMapFb = !opts.silent && actorId === myId;
+ if (showMapFb) showQuizMapQuestionCarryFeedback(true);
+ if (!opts.silent) {
+ const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
+ showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
+ }
+ me.quizCarryHeld = null;
+ others.forEach((o) => { if (o) o.quizCarryHeld = null; });
+ const afterOk = () => { quizCarryAfterRoundResolved(); };
+ if (showMapFb) {
+ window.setTimeout(afterOk, 560);
+ } else {
+ afterOk();
+ }
+ } else {
+ if (!opts.silent && actorId === myId) {
+ showQuizMapQuestionCarryFeedback(false);
+ }
+ if (!opts.silent) {
+ showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
+ }
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ return true;
+ }
+ if (held == null && pickupIdx != null) {
+ if (!quizCarryOptionsPickableNow()) return false;
+ if (quizCarryOptionHeldByAnyone(pickupIdx)) {
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
+ }
+ return false;
+ }
+ ent.quizCarryHeld = pickupIdx;
+ const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
+ if (!opts.silent) {
+ showQuizCarryToast('หยิบ: ' + label, true);
+ }
+ return true;
+ }
+ if (opts.fromKey && actorId === myId && !opts.silent) {
+ const t = Date.now();
+ if (t - (me.quizCarryHintT || 0) > 2200) {
+ me.quizCarryHintT = t;
+ if (held != null && !canSubmitAnswer) {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
+ : 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
+ } else {
+ showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
+ ? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
+ : 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
+ }
+ }
+ }
+ return false;
+ }
+
+ function quizCarryGrabCoreGatesOk() {
+ if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
+ if (quizCarryPregameActive || quizCarrySessionEnded) return false;
+ if (myId == null) return false;
+ if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
+ if (!quizCarryOptionsPickableNow()) return false;
+ return true;
+ }
+
+ /** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */
+ function getQuizCarryLocalGrabbableOptionIndex() {
+ if (!quizCarryGrabCoreGatesOk()) return null;
+ if (me.quizCarryHeld != null) return null;
+ const md = mapData;
+ const footprint = quizTilesFootprintPlay(me.x, me.y);
+ let pickupIdx = null;
+ for (const k of footprint) {
+ const p = k.split(',');
+ const tx = +p[0];
+ const ty = +p[1];
+ const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
+ if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
+ }
+ if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null;
+ return pickupIdx;
+ }
+
+ /** ยืนบนช่องตัวเลือกและหยิบได้ */
+ function quizCarryGrabPickupAvailable() {
+ return getQuizCarryLocalGrabbableOptionIndex() != null;
+ }
+
+ /** ถือป้ายแล้วยืนโซนส่งได้ */
+ function quizCarryGrabSubmitAvailable() {
+ if (!quizCarryGrabCoreGatesOk()) return false;
+ if (me.quizCarryHeld == null) return false;
+ return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
+ }
+
+ /** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
+ function quizCarryGrabInteractionAvailable() {
+ return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
+ }
+
+ function syncQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ if (!isQuizCarry() || !mapData) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('quiz-carry-grab-btn--active');
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.setAttribute('aria-disabled', 'true');
+ return;
+ }
+ const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
+ const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
+ const img = btn.querySelector('img');
+ btn.classList.remove('is-hidden');
+ const active = quizCarryGrabInteractionAvailable();
+ btn.classList.toggle('quiz-carry-grab-btn--active', active);
+ btn.setAttribute('aria-disabled', active ? 'false' : 'true');
+ btn.style.pointerEvents = active ? '' : 'none';
+ if (img) {
+ if (active && quizCarryGrabSubmitAvailable()) {
+ img.src = placeSrc;
+ btn.classList.add('quiz-carry-grab-btn--place');
+ btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
+ btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
+ } else {
+ img.src = grabSrc;
+ btn.classList.remove('quiz-carry-grab-btn--place');
+ btn.title = 'หยิบตัวเลือก — เหมือนกด F';
+ btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
+ }
+ }
+ }
+
+ /** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */
+ function tryRequestGauntletJumpPlay() {
+ if (!mapData || !isGauntlet() || isChatFocused()) return false;
+ if (isGauntletCrownPregameBlockingPlay()) return false;
+ if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false;
+ const now = Date.now();
+ if (now - lastGauntletJumpKey < 200) return false;
+ lastGauntletJumpKey = now;
+ socket.emit('gauntlet-jump');
+ return true;
+ }
+
+ function syncGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ /* MG5 jump_survive (jumper): โชว์ปุ่ม JUMP มุมขวาล่าง (design .mg5-jump 1648/818/231) — ให้ touch กระโดดได้ (เดิม keyboard-only) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) {
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ btn.classList.add('mg2-jump');
+ const jimg = btn.querySelector('img');
+ if (jimg && jimg.src.indexOf('/Jumper/') < 0) jimg.src = jumperAssetUrl('btn-jump.png');
+ const hudJ = document.getElementById('play-cyber-hud');
+ if (hudJ && hudJ.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hudJ);
+ syncMg2MockJumpButtonBoundsPlay();
+ return;
+ }
+ const pregameUi = gauntletCrownPregamePhase === 'howto' || gauntletCrownPregamePhase === 'countdown';
+ const liveUi = gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const show = !!(mapData && isGauntletCrownHeistMapPlay() && !me.gauntletEliminated && (pregameUi || liveUi));
+ if (!show) {
+ btn.classList.add('is-hidden');
+ btn.classList.remove('mg2-jump');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ btn.style.pointerEvents = '';
+ const mg2Jump = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ btn.classList.toggle('mg2-jump', !!mg2Jump);
+ if (mg2Jump) {
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(hud);
+ syncMg2MockJumpButtonBoundsPlay();
+ } else {
+ btn.classList.remove('mg2-jump');
+ }
+ }
+
+ function syncStackTowerDropButtonPlay() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ if (!mapData || !isStack() || !stackMini) {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') {
+ btn.classList.add('is-hidden');
+ btn.setAttribute('aria-hidden', 'true');
+ btn.style.pointerEvents = 'none';
+ btn.classList.remove('stack-tower-drop-btn--active');
+ return;
+ }
+ btn.classList.remove('is-hidden');
+ btn.setAttribute('aria-hidden', 'false');
+ const ok = stackHumanDropGateOkPlay();
+ btn.classList.toggle('stack-tower-drop-btn--active', ok);
+ btn.style.pointerEvents = ok ? '' : 'none';
+ }
+
+ /** MG3 (mnn93hpi): ผู้เล่นตาปัจจุบัน (active node) สำหรับ host portrait ใน DOM HUD */
+ function mg3CurrentSeatRowPlay() {
+ const seat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ let entry = null;
+ if (order && order.length) entry = order.find((e) => e.seat === seat) || null;
+ if (!entry || entry.kind === 'human') {
+ return {
+ id: myId,
+ characterId: me.characterId || getPlayCharacterId(),
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ id: entry.botId,
+ characterId: o ? o.characterId : null,
+ isMe: false,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ /**
+ * MG3 Stack Tower live HUD → DOM overlay (#mg3-tower-hud) — canvas object-fit:cover ครอป HUD ขอบจอ
+ * จึงย้าย life bar (ขวาบน) + progress (ซ้ายล่าง) + code terminal + host frame มาเป็น DOM สเกลตาม stage
+ */
+ function syncStackTowerMissionDomHudPlay() {
+ const root = document.getElementById('mg3-tower-hud');
+ if (!root) return;
+ const on = !!(mapData && isStack() && stackMini && isStackTowerMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+
+ if (!root.dataset.mg3Assets) {
+ const setSrc = (sel, file) => { const el = root.querySelector(sel); if (el) el.src = stackTowerAssetUrl(file); };
+ setSrc('.mg3-life-bar', 'life-bar.png');
+ setSrc('.mg3-host-frame', 'Avartar.png');
+ setSrc('.mg3-terminal-bg', 'code-bg.png');
+ setSrc('.mg3-progress-label', 'progress-txt.png');
+ setSrc('.mg3-progress-track', 'progress-bg.png');
+ setSrc('.mg3-progress-fill', 'progress.png');
+ root.dataset.mg3Assets = '1';
+ }
+
+ /* วาง design plane 1920×1080 → scale แบบ contain + จัดกึ่งกลางใน stage (HUD เห็นครบ ไม่โดน object-fit:cover ครอป) */
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.mg3Box !== key) {
+ root.dataset.mg3Box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+
+ const slots = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ const lives = Math.max(0, Math.min(slots, Math.floor(Number(
+ stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : (stackMini.lives != null ? stackMini.lives : 3),
+ ) || 0)));
+ const heartsEl = document.getElementById('mg3-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + lives;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = stackTowerAssetUrl(i < lives ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+
+ const pct = Math.max(0, Math.min(100, Math.round(Number(stackTowerDecryptPctPlay()) || 0)));
+ if (root.dataset.mg3Pct !== String(pct)) {
+ root.dataset.mg3Pct = String(pct);
+ /* fill เป็น px ในระนาบ design (track ภายในจาก left:30 กว้างสุด ~358px) */
+ const fillEl = document.getElementById('mg3-progress-fill');
+ if (fillEl) fillEl.style.width = Math.round(358 * pct / 100) + 'px';
+ const pctEl = document.getElementById('mg3-progress-pct');
+ if (pctEl) pctEl.textContent = '[ ' + pct + '% ]';
+ }
+
+ const hostAv = document.getElementById('mg3-host-av');
+ if (hostAv) {
+ const row = mg3CurrentSeatRowPlay();
+ const hk = (row.id != null ? row.id : 'me') + '|' + (row.characterId || '');
+ if (hostAv.dataset.hk !== hk) {
+ hostAv.dataset.hk = hk;
+ setCyberHudScoreAvatarImg(hostAv, row);
+ }
+ }
+
+ const logEl = document.getElementById('mg3-terminal-log');
+ if (logEl) {
+ const towerLiveNow = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (towerLiveNow && (!mg3TermLog || !mg3TermLog.length)) mg3TermBoot();
+ else if (!towerLiveNow && mg3TermLog && mg3TermLog.length) mg3TermReset();
+ let lines;
+ if (mg3TermLog && mg3TermLog.length) lines = mg3TermLog.slice(-9);
+ else if (stackPreviewHudLog && stackPreviewHudLog.length) lines = stackPreviewHudLog.slice(-7);
+ else lines = ['>>> DECRYPT UPLINK READY', '>>> SPACE / CLICK = DROP (ตาคุณ)'];
+ const txt = lines.join('\n');
+ if (logEl.textContent !== txt) logEl.textContent = txt;
+ }
+ }
+
+ /**
+ * MG6 Space Shooter life → overlay #mg6-life-hud (ระนาบ design 1920×1080 scale ตาม stage แบบเดียวกับ MG3)
+ * life-bar.png + หัวใจ 3 ดวง (ViolentCrime) ตำแหน่ง/ขนาดเท่า MG3 — ไม่พึ่ง flex ของ cyber HUD (กันบีบ/ล้นขอบ)
+ */
+ function syncSpaceShooterLifeDomHudPlay() {
+ const root = document.getElementById('mg6-life-hud');
+ if (!root) return;
+ const on = !!(mapData && isSpaceShooter() && isSpaceShooterMissionHudActivePlay());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ if (!root.dataset.assets) {
+ const bar = root.querySelector('.mg6-life-bar');
+ if (bar) bar.src = violentCrimeAssetUrl('life-bar.png');
+ root.dataset.assets = '1';
+ }
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const slots = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS;
+ const hits = Math.max(0, Math.min(slots, Number(me.spaceShooterHits) || 0));
+ const rem = slots - hits;
+ const heartsEl = document.getElementById('mg6-life-hearts');
+ if (heartsEl) {
+ const hk = slots + '/' + rem;
+ if (heartsEl.dataset.hk !== hk) {
+ heartsEl.dataset.hk = hk;
+ heartsEl.innerHTML = '';
+ for (let i = 0; i < slots; i++) {
+ const im = document.createElement('img');
+ im.src = violentCrimeAssetUrl(i < rem ? 'life-full.png' : 'life-hit.png');
+ im.alt = '';
+ im.decoding = 'async';
+ heartsEl.appendChild(im);
+ }
+ }
+ }
+ }
+
+ /** Quiz Battle (qbroom*) เลขห้องจาก mapId → tag/หมวด */
+ function quizBattleRoomNoPlay() {
+ const m = String(currentPlayMapId() || '').match(/qbroom(\d+)/i);
+ return m ? Math.max(1, Math.min(10, parseInt(m[1], 10))) : 0;
+ }
+
+ /**
+ * Quiz Battle in-game HUD → overlay #qb-game-hud (design plane 1920×1080 scale ตาม stage)
+ * header หมวด (tag-NN), PLAYERS x/50, SCORE (ตอบถูก), host avatar. EXIT/MY RESULT/RANKING wire แยก
+ */
+ function syncQuizBattleHudPlay() {
+ const root = document.getElementById('qb-game-hud');
+ if (!root) return;
+ const on = !!(mapData && isQuizBattle());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) return;
+ /* ซ่อนกล่อง dev instruction ('QUIZ BATTLE · โหลดคำถามแล้ว...') มุมซ้ายล่าง — ไม่มีใน design */
+ const qgOv = document.getElementById('quiz-game-overlay');
+ if (qgOv && !qgOv.classList.contains('is-hidden')) qgOv.classList.add('is-hidden');
+ const stack = document.getElementById('play-canvas-stack');
+ if (stack) {
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || 0);
+ const ch = Math.max(1, r.height || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (s > 0 && isFinite(s)) {
+ const left = Math.round(r.left + (cw - 1920 * s) / 2);
+ const top = Math.round(r.top + (ch - 1080 * s) / 2);
+ const key = left + ',' + top + ',' + s.toFixed(4);
+ if (root.dataset.box !== key) {
+ root.dataset.box = key;
+ root.style.left = left + 'px';
+ root.style.top = top + 'px';
+ root.style.transform = 'scale(' + s + ')';
+ }
+ }
+ }
+ const roomNo = quizBattleRoomNoPlay();
+ const tag = document.getElementById('qb-hud-tag');
+ if (tag && roomNo > 0) {
+ const want = BASE + '/img/qb-ui/tag-' + (roomNo < 10 ? '0' + roomNo : String(roomNo)) + '.png';
+ if (tag.dataset.src !== want) { tag.dataset.src = want; tag.src = want; }
+ }
+ const pv = document.getElementById('qb-hud-players-val');
+ if (pv) {
+ const n = countPlayHumans();
+ const max = (mapData && mapData.maxPlayers) || 50;
+ const txt = n + '/' + max;
+ if (pv.textContent !== txt) pv.textContent = txt;
+ }
+ const sv = document.getElementById('qb-hud-score-val');
+ if (sv) {
+ const sc = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ if (sv.textContent !== String(sc)) sv.textContent = String(sc);
+ }
+ const av = document.getElementById('qb-hud-host-av');
+ if (av) {
+ const cid = me.characterId || getPlayCharacterId();
+ if (cid && av.dataset.cid !== String(cid)) {
+ av.dataset.cid = String(cid);
+ /* full-body down avatar (เหมือนหน้า Quiz-Battle/.qb-room-profile-avatar) clip โชว์แค่ส่วนบน */
+ ensurePlayLobbyIdleAvatarImage(cid);
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) {
+ av.src = src;
+ } else {
+ const im = getCharacterImg(cid, 'down');
+ if (im && im.src) av.src = im.src;
+ }
+ }
+ }
+ }
+
+ function resetQuizCarryPlayState() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ const qcmo = document.getElementById('quiz-carry-mission-overlay');
+ if (qcmo) qcmo.classList.add('is-hidden');
+ quizCarryPregameActive = false;
+ hideQuizCarryPregameOverlay();
+ quizCarryPool = [];
+ quizCarryCurrent = null;
+ quizCarryEmbedPendingQuestion = null;
+ quizCarryEmbedCountdownStartAt = 0;
+ quizCarryEmbedCountdownEndAt = 0;
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryOptionRevealAt = 0;
+ quizCarryAnswerCloseAt = 0;
+ quizCarryAnswerTimeupAwaitNext = false;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryEmbedCountdownOverlay();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ me.quizCarryHeld = null;
+ others.forEach((o) => {
+ if (!o) return;
+ o.quizCarryHeld = null;
+ o.botPath = [];
+ });
+ }
+
+ /** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
+ async function mergeQuizCarrySettingsFromDisk(s) {
+ const out = s && typeof s === 'object' ? s : {};
+ try {
+ const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
+ if (rd.ok) {
+ const disk = await rd.json();
+ if (disk && typeof disk === 'object') {
+ if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
+ out.carryMapPanelTheme = disk.carryMapPanelTheme;
+ }
+ if (disk.quizMapPanelTheme && typeof disk.quizMapPanelTheme === 'object') {
+ out.quizMapPanelTheme = disk.quizMapPanelTheme;
+ }
+ if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
+ out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
+ }
+ if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
+ out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
+ } else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
+ out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
+ }
+ if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
+ if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
+ if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
+ if (disk.carryWalkSpeedMultForMapId != null) {
+ out.carryWalkSpeedMultForMapId = String(disk.carryWalkSpeedMultForMapId).trim();
+ }
+ if (disk.carryWalkSpeedMult != null) {
+ const wm = Number(disk.carryWalkSpeedMult);
+ if (Number.isFinite(wm)) out.carryWalkSpeedMult = wm;
+ }
+ const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
+ if (Number.isFinite(diskPlaqueScale)) {
+ out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
+ }
+ if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
+ out.carryQuestions = disk.carryQuestions;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ return out;
+ }
+
+ async function loadQuizCarryPoolAndStart() {
+ cancelQuizCarrySessionCompleteResultToSummary();
+ quizCarryEmbedPreOptionCountdownStartAt = 0;
+ quizCarryEmbedPreOptionCountdownEndAt = 0;
+ quizCarryRoundsCompleted = 0;
+ quizCarrySessionEnded = false;
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ quizCarrySessionLength = 0;
+ let pool = [];
+ let s = {};
+ const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null;
+ try {
+ const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
+ if (r.ok) {
+ const raw = await r.text();
+ const trimmed = (raw || '').trim();
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
+ const parsed = JSON.parse(trimmed);
+ if (parsed && typeof parsed === 'object') s = parsed;
+ }
+ }
+ } catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ }
+ try {
+ s = await mergeQuizCarrySettingsFromDisk(s);
+ } catch (e) { /* ignore */ }
+ if (snap) {
+ if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs;
+ if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs;
+ if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength;
+ }
+ if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') {
+ s.carryMapPanelTheme = snap.carryMapPanelTheme;
+ }
+ if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') {
+ s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme;
+ }
+ /* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */
+ if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) {
+ if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) {
+ s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes;
+ } else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') {
+ s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme;
+ }
+ }
+ if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) {
+ const sm = Number(snap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm;
+ }
+ if (snap) {
+ if ((s.carryWalkSpeedMultForMapId == null || String(s.carryWalkSpeedMultForMapId).trim() === '') && snap.carryWalkSpeedMultForMapId != null) {
+ s.carryWalkSpeedMultForMapId = snap.carryWalkSpeedMultForMapId;
+ }
+ if (s.carryWalkSpeedMult == null && snap.carryWalkSpeedMult != null) s.carryWalkSpeedMult = snap.carryWalkSpeedMult;
+ }
+ applyQuizCarryWalkSpeedFromSettingsObj(s);
+ {
+ const sc = Number(s.carryChoicePlaqueMapScale);
+ quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25;
+ }
+ try {
+ quizCarryCarryTimingMs = {
+ carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
+ carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
+ };
+ quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
+ setQuizCarryMapPanelThemeFromApi(s);
+ setQuizCarryEmbedCountdownThemeFromApi(s);
+ setQuizCarryChoicePlaqueThemeFromApi(s);
+ for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) {
+ const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl);
+ if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue;
+ const pim = new Image();
+ quizCarryApplyImageCrossOrigin(pim, tUrl);
+ pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
+ quizCarryChoiceImageCache.set(tUrl, pim);
+ try {
+ pim.src = tUrl;
+ } catch (e) { /* ignore */ }
+ }
+ const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
+ const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
+ for (let i = 0; i < source.length; i++) {
+ const n = normalizeQuizCarryQuestionFromAny(source[i]);
+ if (n) pool.push(n);
+ }
+ } catch (e) { /* map only */ }
+ if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
+ quizCarryPool = pool;
+ for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]);
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ if (!pool.length) {
+ playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = playQuizText;
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ return;
+ }
+ if (quizCarryEmbedMissionFlowActive()) {
+ beginQuizCarryEmbedPregame();
+ } else {
+ quizCarryPickNextQuestion();
+ }
+ const nowJoin = Date.now();
+ if (!quizCarryEmbedMissionFlowActive()) {
+ quizCarryRestoreEmbedPhaseLabel();
+ } else if (!(quizCarryEmbedCountdownEndAt > nowJoin) && !(quizCarryEmbedPreOptionCountdownEndAt > nowJoin)) {
+ quizCarryRestoreEmbedPhaseLabel();
+ }
+ try {
+ syncPlayQuizMapPanel();
+ } catch (e) { /* ignore */ }
+ quizCarryJoinSettingsSnap = null;
+ }
+
+ function setupPlayQuizCarryUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) {
+ if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ } else {
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+ }
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = isDetectiveMinigamePlay()
+ ? ''
+ : (quizCarryMapHasAnswerInteractive(mapData)
+ ? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
+ : 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย');
+ }
+ resetQuizCarryPlayState();
+ if (previewMode) {
+ loadQuizCarryPoolAndStart();
+ } else {
+ playQuizText = isDetectiveMinigamePlay() ? '' : 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
+ loadQuizCarryPoolAndStart();
+ }
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ syncQuizCarryGrabButton();
+ }
+
+ function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const want = optionIndex + 1;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryHub(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileInQuizCarryInteractive(md, o) {
+ const w = md.width || 20, h = md.height || 15;
+ const pool = [];
+ for (let y = 0; y < h; y++) {
+ const row = md.interactive && md.interactive[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
+ if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
+ }
+ }
+ }
+ if (!pool.length) return null;
+ return pool[Math.floor(Math.random() * pool.length)];
+ }
+
+ function pickRandomTileForQuizCarrySubmit(md, o) {
+ if (quizCarryMapHasAnswerInteractive(md)) {
+ const p = pickRandomTileInQuizCarryInteractive(md, o);
+ if (p) return p;
+ }
+ return pickRandomTileInQuizCarryHub(md, o);
+ }
+
+ /** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */
+ function quizCarryPreviewBotLobbyWanderStep(o, w, h) {
+ const now = Date.now();
+ o.botIsWalking = false;
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED * quizCarryWalkSpeedMultActive;
+ const nx = o.x + accX * step;
+ const ny = o.y + accY * step;
+ const ox = o.x;
+ const oy = o.y;
+ if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ }
+
+ function stepQuizCarryPreviewBots() {
+ if (quizCarrySessionEnded) return;
+ if (quizCarryPregameActive) return;
+ if (!playBotsEnabled() || !isQuizCarry() || !mapData) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const nowPf = performance.now();
+ const pathfindMinGap = editorEmbedReturn ? 280 : 140;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
+ const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
+ if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
+ o.x = sn.x;
+ o.y = sn.y;
+ o.botPath = [];
+ o.botPathStuckTicks = 0;
+ o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ if (!o.botPath || o.botPath.length === 0) {
+ tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
+ }
+ /* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */
+ if (!quizCarryCurrent) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const tier = o.botTier || 'avg';
+ const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
+ const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
+ : Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
+ const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
+ if (!o.botPath || !o.botPath.length) {
+ if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
+ if (nowPf < o.botQuizCarryPathfindAfter) {
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ return;
+ }
+ const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
+ if (quizCarryOptionsPickableNow()) {
+ if (o.quizCarryHeld == null && wantIdx != null) {
+ const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
+ if (dest) {
+ const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ } else {
+ const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
+ if (sub) {
+ const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
+ if (path && path.length > 1) o.botPath = path.slice(1);
+ }
+ }
+ o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
+ } else {
+ o.botQuizCarryPathfindAfter = nowPf + 120;
+ quizCarryPreviewBotLobbyWanderStep(o, w, h);
+ }
+ } else {
+ stepPreviewBotAlongPath(o, w, h);
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ }
+
+ function getStackAreaBoundsPlay(area, w, h) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ let any = false;
+ for (let y = 0; y < h; y++) {
+ const row = area && area[y];
+ if (!row) continue;
+ for (let x = 0; x < w; x++) {
+ if (row[x] === 1) {
+ any = true;
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ if (!any) return null;
+ return {
+ minX, maxX, minY, maxY,
+ cx: (minX + maxX + 1) / 2,
+ cy: (minY + maxY + 1) / 2,
+ };
+ }
+
+ /** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */
+ function getStackTowerCameraFollowPlayConfig() {
+ return { enabled: false, pxPerLayer: 12, maxPx: 260 };
+ }
+
+ /** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */
+ function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) {
+ if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0;
+ const n = Math.floor(Number(layerCount) || 0);
+ if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0;
+ const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3));
+ const tiers = n - (STACK_TOWER_SWING_LIFT_FROM_LAYER - 1);
+ return Math.min(lh * 20, tiers * lh * 0.98);
+ }
+
+ /**
+ * progress ≥เกณฑ์ Tower + ชั้นพอ: ไม่วาดชั้นล่าง (ข้าม draw) — ตำแหน่ง Y ยังใช้ชั้นจริง (floorY-(i+1)*h)
+ * เพื่อไม่ให้กองลอยหลุดจากฐานแมปเมื่อเทียบกริด
+ */
+ function getStackTowerVisualHiddenLayerCountPlay() {
+ if (!isStackTowerMissionHudActivePlay() || !stackMini) return 0;
+ const p = Number(stackMini.progressPct) || 0;
+ if (p < STACK_TOWER_POST50_PROGRESS_THRESH) return 0;
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ if (n < 4) return 0;
+ return Math.floor(n / 2);
+ }
+
+ /**
+ * world px ตามความสูงหอจริง (mnn93hpi) — คูณ z แล้วได้พิกเซลจอสำหรับเลื่อนแถบ BG
+ * ใช้ n × layerWorldH (ความสูงชั้นตามบล็อก) คล้มด้วย maxPx จากแมป
+ */
+ function getStackTowerBgScrollHeightBoostPx() {
+ if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
+ const cfg = getStackTowerCameraFollowPlayConfig();
+ if (!cfg.enabled || cfg.pxPerLayer <= 0) return 0;
+ const lh = Math.max(10, Number(stackMini.layerWorldH) || Math.max(14, (tileSize || 32) * 0.31));
+ const n = stackMini.layers ? stackMini.layers.length : 0;
+ let layerCount = n;
+ if (stackFall) {
+ const dur = Math.max(1, stackFall.dur || 400);
+ const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur));
+ layerCount = n + u * 0.9;
+ }
+ return Math.min(cfg.maxPx, layerCount * lh);
+ }
+
+ /** กล้อง stack: จุดกลางจาก land / release ของแมป (Tower ไม่เลื่อน cy ตามกอง) */
+ function getStackCameraCentersPx() {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let cx = (w / 2) * ts;
+ let cy = (h / 2) * ts;
+ if (land && rel) {
+ cx = land.cx * ts;
+ cy = ((land.cy + rel.cy) / 2) * ts;
+ } else if (land) {
+ cx = land.cx * ts;
+ cy = land.cy * ts;
+ } else if (rel) {
+ cx = rel.cx * ts;
+ cy = rel.cy * ts;
+ }
+ return { px: cx, py: cy };
+ }
+
+ /** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
+ function reapplyStackMiniSizingFromGlobals() {
+ if (!mapData || !isStack() || !stackMini) return;
+ if (stackMini.layers && stackMini.layers.length > 0) return;
+ if (stackFall || stackMini.settling) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackMini.initialWidthTiles = iw;
+ stackMini.widthTiles = iw;
+ markStackNextDropVisualDirty();
+ }
+
+ function resetStackMinigameState() {
+ stackMini = null;
+ stackTowerPost50AnimStartMs = null;
+ stackTowerHeartMinusFx = null;
+ if (!mapData || !isStack()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
+ let towerCX = w * 0.5;
+ let swingAmp = 1.85;
+ let floorWorldY = (h - 1) * tileSize;
+ let swingWorldY = floorWorldY - tileSize * 1.35;
+ if (land) {
+ towerCX = land.cx;
+ floorWorldY = (land.maxY + 1) * tileSize;
+ const span = Math.max(1, land.maxX - land.minX + 1);
+ swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
+ }
+ if (rel) {
+ swingWorldY = (rel.cy + 0.5) * tileSize;
+ const spanR = Math.max(1, rel.maxX - rel.minX + 1);
+ swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
+ }
+ const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
+ const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
+ ? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
+ : autoW;
+ stackFall = null;
+ const towerMission = isStackTowerMissionUiMapPlay();
+ const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
+ const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul;
+ const missMax = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
+ stackMini = {
+ topCenterX: towerCX,
+ widthTiles: initW,
+ initialWidthTiles: initW,
+ craneWorldX: towerCX * tileSize,
+ swingAmp,
+ phase: Math.random() * Math.PI * 2,
+ phaseSpeed: playStackSwingHz,
+ floorWorldY,
+ swingWorldY,
+ layerWorldH: towerMission ? towerLayerStripH : Math.max(14, tileSize * 0.3),
+ blockStripH: towerMission ? towerLayerStripH : Math.max(16, tileSize * 0.32),
+ layers: [],
+ lives: 3,
+ teamMissesLeft: towerMission ? missMax : null,
+ progressPct: 0,
+ score: 0,
+ combo: 0,
+ lastDropAt: 0,
+ stackTiltRad: 0,
+ stackTiltVel: 0,
+ nextDropVisualDirty: true,
+ pendingDropSeat: 1,
+ pendingDropHeavy: false,
+ scorePopups: [],
+ perfectStackX2Fx: null,
+ };
+ if (playBotsEnabled()) {
+ me.stackPreviewHumanPts = 0;
+ stackPreviewHudLog = [];
+ }
+ ensureStackTowerSlingImagePlay();
+ if (towerMission || playBotsEnabled()) {
+ ensureStackTowerLifeHudImagesPlay();
+ ensureStackTowerHeartMinusImgPlay();
+ }
+ updateStackTowerSwingYForStripGapPlay();
+ }
+
+ function stackEaseDrop(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t;
+ }
+
+ /** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
+ function stackEaseDropPhys(t, sloppyN) {
+ t = Math.max(0, Math.min(1, t));
+ if (sloppyN < 0.14) return t * t;
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ }
+
+ /**
+ * @param {object} hit
+ * @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
+ */
+ function applyStackHitFromDrop(hit, previewAttribution, layerVisualMeta) {
+ if (!stackMini || !hit) return;
+ const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
+ if (hit.miss) {
+ stackMini.combo = 0;
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ if (stackMini.layers && stackMini.layers.length) {
+ for (let gi = 0; gi < stackMini.layers.length; gi++) {
+ const lay = stackMini.layers[gi];
+ if (lay && lay.perfectGlowUntil != null) delete lay.perfectGlowUntil;
+ }
+ }
+ if (towerLive && stackMini.teamMissesLeft != null) {
+ stackMini.teamMissesLeft = Math.max(0, stackMini.teamMissesLeft - 1);
+ } else {
+ stackMini.lives = Math.max(0, stackMini.lives - 1);
+ if (stackMini.lives <= 0) {
+ stackMini.score = Math.max(0, stackMini.score - 2);
+ stackMini.lives = 3;
+ stackMini.layers = [];
+ stackMini.scorePopups = [];
+ stackMini.perfectStackX2Fx = null;
+ stackMini.widthTiles = stackMini.initialWidthTiles;
+ const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
+ stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
+ stackMini.combo = 0;
+ stackMini.stackTiltRad = 0;
+ stackMini.stackTiltVel = 0;
+ }
+ }
+ if (towerLive) {
+ triggerStackTowerHeartMinusFxPlay();
+ const remain = stackMini.teamMissesLeft != null ? stackMini.teamMissesLeft : Math.max(0, (stackMini.lives || 0));
+ mg3TermOnMiss(Number(remain) || 0);
+ }
+ } else {
+ const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat();
+ const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy);
+ const placedW = hit.placedW != null && Number.isFinite(hit.placedW)
+ ? Math.max(0.85, Math.min(3.2, hit.placedW))
+ : stackMini.initialWidthTiles;
+ stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay });
+ const nAfter = stackMini.layers.length;
+ maybeQueueStackTowerBgScrollStepPlay(nAfter);
+ const perfectFull =
+ towerLive &&
+ hit.perfect &&
+ !hit.miss &&
+ nAfter >= 2 &&
+ hit.supportRatio != null &&
+ hit.supportRatio >= STACK_TOWER_PERFECT_SUPPORT_MIN;
+ if (perfectFull) {
+ const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS;
+ stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil;
+ stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil;
+ }
+ if (towerLive && (perfectFull || hit.pts >= 20)) {
+ ensureStackTowerPerfectFxImagesPlay();
+ stackMini.perfectStackX2Fx = {
+ until: performance.now() + STACK_TOWER_PERFECT_X2_MS,
+ topLayerIndex: nAfter - 1,
+ };
+ } else if (towerLive) {
+ stackMini.perfectStackX2Fx = null;
+ }
+ stackMini.score += hit.pts;
+ if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) {
+ let np = Number(stackMini.progressPct) + Number(hit.progressDelta);
+ np = Math.min(100, Math.max(0, np));
+ if (np > 99.999) np = 100;
+ stackMini.progressPct = np;
+ markStackNextDropVisualDirty();
+ }
+ if (playBotsEnabled() && previewAttribution) {
+ if (previewAttribution.human) {
+ me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
+ } else if (previewAttribution.botId) {
+ const ob = others.get(previewAttribution.botId);
+ if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
+ }
+ }
+ stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
+ stackMini.topCenterX = hit.placedCx;
+ if (towerLive && !hit.miss && hit.pts > 0) {
+ const pNow = Number(stackMini.progressPct);
+ const prog100 = Number.isFinite(pNow) && pNow >= 99.5;
+ const kind20 = hit.pts >= 20 || prog100;
+ if (!stackMini.scorePopups) stackMini.scorePopups = [];
+ const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS;
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' });
+ if (kind20 && perfectFull && nAfter >= 2) {
+ stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' });
+ }
+ while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift();
+ }
+ if (towerLive && !hit.miss) mg3TermOnPlace(perfectFull || !!hit.perfect, stackMini.progressPct);
+ }
+ }
+
+ /**
+ * Editor embed เท่านั้น: ปรับ `stackMini.phase` ให้ sin·swingAmp ใกล้จุดรองรับก่อนปล่อย — บอทฉลาด/กลาง/พลาดบ่อย ต่างค่า aim error
+ */
+ function previewBotAlignStackSwingPhasePlay(m, botPeer) {
+ if (!playBotsEnabled() || !m || !mapData) return;
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg';
+ const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05);
+ const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
+ const tri = Math.random() + Math.random() - 1;
+ let errTiles = tri * errMax * layerTight;
+ if (m.layers.length >= 2) {
+ const L = m.layers.length;
+ const step = m.layers[L - 1].cx - m.layers[L - 2].cx;
+ errTiles -= step * 0.28;
+ }
+ let targetCx = supportCx + errTiles;
+ if (land && m.layers.length > 0) {
+ const pull = (land.cx - supportCx) * 0.1;
+ const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
+ targetCx += Math.max(-cap, Math.min(cap, pull));
+ }
+ const rawWant = targetCx - m.topCenterX;
+ const amp = Math.max(1e-5, m.swingAmp);
+ const s = Math.max(-1, Math.min(1, rawWant / amp));
+ const phi0 = Math.asin(s);
+ const phi1 = Math.PI - phi0;
+ const twoPi = Math.PI * 2;
+ function nearestPhase(anchor, p) {
+ let best = p;
+ let bestD = Infinity;
+ for (let k = -5; k <= 5; k++) {
+ const cand = p + k * twoPi;
+ const d = Math.abs(cand - anchor);
+ if (d < bestD) {
+ bestD = d;
+ best = cand;
+ }
+ }
+ return best;
+ }
+ const n0 = nearestPhase(m.phase, phi0);
+ const n1 = nearestPhase(m.phase, phi1);
+ m.phase = Math.abs(n0 - m.phase) <= Math.abs(n1 - m.phase) ? n0 : n1;
+ }
+
+ /**
+ * ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน)
+ * พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*)
+ */
+ function computeStackDropResult(m) {
+ const raw = m.swingAmp * Math.sin(m.phase);
+ const wMap = mapData.width || 20;
+ const hMap = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
+ const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
+ const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
+ const wMul = getStackTowerBlockWorldScalePlay();
+ const fallWEff = fallW * wMul;
+ let supportCx;
+ let supportW;
+ if (m.layers.length === 0) {
+ supportCx = land ? land.cx : wMap * 0.5;
+ supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
+ } else {
+ const sup = m.layers[m.layers.length - 1];
+ supportCx = sup.cx;
+ const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
+ supportW = Math.max(0.85, Math.min(3.2, swR));
+ }
+ const c1 = m.topCenterX + raw;
+ const half1 = fallWEff * 0.5;
+ const half0 = (m.layers.length === 0 ? supportW : supportW * wMul) * 0.5;
+ const left = Math.max(supportCx - half0, c1 - half1);
+ const right = Math.min(supportCx + half0, c1 + half1);
+ const overlap = right - left;
+ const missThreshold =
+ m.layers.length === 0
+ ? Math.max(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND)
+ : Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER);
+ const overlapMiss = overlap < missThreshold;
+ const placedCx = c1;
+ let driftMiss = false;
+ if (!overlapMiss && m.layers.length > 0) {
+ const anchorCx = land ? land.cx : wMap * 0.5;
+ let maxDriftTiles;
+ if (land) {
+ const landSpan = Math.max(1, land.maxX - land.minX + 1);
+ maxDriftTiles = landSpan * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND;
+ } else {
+ maxDriftTiles = Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT);
+ }
+ if (Math.abs(placedCx - anchorCx) > maxDriftTiles) driftMiss = true;
+ }
+ const miss = overlapMiss || driftMiss;
+ const delta = c1 - supportCx;
+ const span = supportW * 0.5 + fallW * 0.5;
+ const offN = Math.abs(delta) / Math.max(0.01, span);
+ const perfect = !miss && offN < 0.055;
+ const comboBefore = m.combo || 0;
+ let pts;
+ let progressDelta = 0;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live') {
+ const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1;
+ const progPerLayer = 100 / stackTowerProgressBlocksPlay();
+ pts = miss ? 0 : (10 * mult);
+ progressDelta = miss ? 0 : (progPerLayer * mult);
+ } else {
+ const bonus = perfect ? Math.min(4, comboBefore) : 0;
+ pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
+ }
+ const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0;
+ return {
+ miss,
+ perfect,
+ pts,
+ progressDelta,
+ landOffsetTiles: raw,
+ placedW: fallW,
+ placedCx,
+ overlap,
+ supportRatio,
+ delta,
+ };
+ }
+
+ /**
+ * เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
+ * @param {object} hit จาก computeStackDropResult
+ * @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
+ * @returns {boolean}
+ */
+ function startStackFallAnimation(hit, previewActor) {
+ if (!stackMini || !hit || stackFall || stackMini.settling) return false;
+ ensureStackNextDropVisual();
+ const m = stackMini;
+ const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat();
+ const actorHeavy = !!m.pendingDropHeavy;
+ const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay();
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh);
+ const swingAttachY = m.swingWorldY - swingLift;
+ let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
+ if (y1 <= swingAttachY) y1 = swingAttachY + lh * 0.55;
+ const landOff = hit.landOffsetTiles || 0;
+ const landCxWorld = (m.topCenterX + landOff) * tileSize;
+ const xLeft0 = swingXW - tw / 2;
+ const xLeft1 = landCxWorld - tw / 2;
+ const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
+ const dur = Math.max(400, Math.min(1280, 240 + (y1 - swingAttachY) * 0.92 + offN * 560));
+ const tiltMax = hit.miss
+ ? 0.28 * (0.25 + offN)
+ : 0.16 * offN * (hit.perfect ? 0.1 : 1);
+ const isHumanPreview = !!(previewActor && previewActor.human);
+ const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
+ const cableTopYRel = isStackTowerMissionUiMapPlay()
+ ? (swingAttachY - tileSize * 6)
+ : Math.max(0, swingAttachY - tileSize * 6);
+ const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ const releaseRopeAng = Math.atan2(swingAttachY - cableTopYRel, swingXW - craneXRel);
+ stackFall = {
+ t0: performance.now(),
+ dur,
+ xLeft0,
+ xLeft1,
+ tw,
+ y0: swingAttachY,
+ y1,
+ hit,
+ tiltMax,
+ sloppyN: offN,
+ previewHumanDrop: isHumanPreview,
+ previewBotId: botIdPreview,
+ releaseRopeAng,
+ releaseAttachWorldX: swingXW,
+ releaseAttachWorldY: swingAttachY,
+ actorSeat,
+ actorHeavy,
+ };
+ return true;
+ }
+
+ function stackHumanDropGateOkPlay() {
+ if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false;
+ if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
+ if (Date.now() - stackMini.lastDropAt < 480) return false;
+ return true;
+ }
+
+ /** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
+ function tryHumanStackDrop() {
+ if (!stackHumanDropGateOkPlay()) return false;
+ ensureStackNextDropVisual();
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
+ if (playBotsEnabled()) {
+ lastStackPreviewActorId = '__human__';
+ lastStackPreviewActorUntil = Date.now() + 900;
+ }
+ return true;
+ }
+
+ /** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
+ function simulatePreviewBotStackDrop(botId, o) {
+ if (!stackMini || stackFall || stackMini.settling) return;
+ ensureStackNextDropVisual();
+ previewBotAlignStackSwingPhasePlay(stackMini, o);
+ const hit = computeStackDropResult(stackMini);
+ if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
+ o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ lastStackPreviewActorId = botId;
+ lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
+ }
+
+ /** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
+ function stepPreviewStackTurnBased(nowMs) {
+ if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return;
+ if (mapData && mapData.gameType === 'stack' &&
+ (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
+ rebuildStackPreviewTurnOrder();
+ }
+ const order = stackPreviewTurnOrder;
+ if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
+ const cur = order[stackPreviewTurnIndex];
+ if (!cur || cur.kind !== 'bot') return;
+ if (!cur.botId) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ const o = others.get(cur.botId);
+ if (!o) {
+ advanceStackPreviewTurn();
+ return;
+ }
+ if (stackPreviewBotThinkUntil === 0) {
+ const tier = o.botTier === 'sharp' ? 'sharp' : o.botTier === 'weak' ? 'weak' : 'avg';
+ const thinkLo = tier === 'sharp' ? 160 : tier === 'avg' ? 260 : 340;
+ const thinkHi = tier === 'sharp' ? 340 : tier === 'avg' ? 520 : 620;
+ stackPreviewBotThinkUntil = nowMs + thinkLo + Math.random() * (thinkHi - thinkLo);
+ return;
+ }
+ if (nowMs < stackPreviewBotThinkUntil) return;
+ stackPreviewBotThinkUntil = 0;
+ simulatePreviewBotStackDrop(cur.botId, o);
+ }
+
+ function stackTickFrame() {
+ stackTowerMissionMaybeEndPlay();
+ const now = performance.now();
+ const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
+ lastStackTickMs = now;
+ if (stackMini && stackMini.settling) {
+ const s = stackMini.settling;
+ const u = (now - s.t0) / s.dur;
+ if (u >= 1) {
+ const wasHuman = !!s.previewHumanDrop;
+ const settleBotId = s.previewBotId || null;
+ applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
+ if (playBotsEnabled() && (wasHuman || settleBotId)) {
+ stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
+ }
+ stackMini.settling = null;
+ stackMini.lastDropAt = Date.now();
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn();
+ }
+ } else if (stackFall) {
+ const t = (now - stackFall.t0) / stackFall.dur;
+ if (t >= 1) {
+ const wasHuman = !!stackFall.previewHumanDrop;
+ const fallBotId = stackFall.previewBotId || null;
+ const hit = stackFall.hit;
+ const stableMin = STACK_STABLE_SUPPORT_RATIO_MIN;
+ if (hit.miss) {
+ spawnStackMissDebrisPlay(stackFall);
+ applyStackHitFromDrop(hit);
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ } else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
+ const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
+ stackMini.settling = {
+ t0: performance.now(),
+ dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
+ hit,
+ xLeft0: stackFall.xLeft1,
+ yTop0: stackFall.y1,
+ twWorld: stackFall.tw,
+ stripH,
+ delta: hit.delta || 0,
+ previewHumanDrop: wasHuman,
+ previewBotId: fallBotId,
+ actorSeat: stackFall.actorSeat,
+ actorHeavy: stackFall.actorHeavy,
+ };
+ stackFall = null;
+ } else {
+ let previewAttr;
+ if (playBotsEnabled()) {
+ if (wasHuman) previewAttr = { human: true };
+ else if (fallBotId) previewAttr = { botId: fallBotId };
+ }
+ applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy });
+ if (playBotsEnabled() && (wasHuman || fallBotId)) {
+ stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
+ }
+ stackMini.lastDropAt = Date.now();
+ stackFall = null;
+ markStackNextDropVisualDirty();
+ if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
+ }
+ }
+ } else if (stackMini) {
+ stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
+ }
+ stepPreviewStackTurnBased(Date.now());
+ }
+
+ /**
+ * จุดบนจอที่เส้นก้าน→บล็อกตัดแนว y = -(ความสูงแคนวาส × STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC)
+ * — ปลายเชือกอยู่เหนือขอบบน ~25% ของความสูงจอ (มองเห็นช่วงที่เข้ามาในจอ)
+ */
+ function stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, canvasH) {
+ const ch = Math.max(80, Number(canvasH) || 1080);
+ const marginTop = -ch * STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC;
+ const ddx = tcx - slingBx;
+ const ddy = tcy - slingBy;
+ if (Math.abs(ddy) < 1e-4) {
+ return { x: tcx, y: Math.min(marginTop, Math.min(tcy, slingBy)) };
+ }
+ const vTop = (marginTop - slingBy) / ddy;
+ if (!Number.isFinite(vTop) || vTop < 0) {
+ return { x: tcx, y: tcy };
+ }
+ const xTop = slingBx + vTop * ddx;
+ return { x: xTop, y: marginTop };
+ }
+
+ function applyStackPreviewSpawnLayout() {
+ if (!mapData || mapData.gameType !== 'stack') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
+ const cx = land ? land.cx : w / 2 - 0.5;
+ const cy = land ? land.cy : h / 2 - 0.5;
+ me.x = cx;
+ me.y = cy;
+ me.tx = cx;
+ me.ty = cy;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.x = cx + (Math.random() - 0.5) * 0.15;
+ o.y = cy + (Math.random() - 0.5) * 0.15;
+ o.tx = o.x;
+ o.ty = o.y;
+ o.stackBotScore = 0;
+ o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
+ });
+ }
+
+ function drawStackMinigame(ctx, worldToScreen, zoom) {
+ if (!stackMini) return;
+ const m = stackMini;
+ const towerHudLive = isStackTowerMissionHudActivePlay();
+ const wMul = getStackTowerBlockWorldScalePlay();
+ ensureStackNextDropVisual();
+ const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
+ const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
+ const floorY = m.floorWorldY;
+ const swingLiftDraw = getStackTowerSwingLiftWorldPx(m.layers.length, layerWorldH);
+ const swingDrawY = m.swingWorldY - swingLiftDraw;
+ const hidLayers = towerHudLive ? getStackTowerVisualHiddenLayerCountPlay() : 0;
+
+ const nLay = m.layers.length;
+ for (let i = hidLayers; i < nLay; i++) {
+ const L = m.layers[i];
+ const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxW = L.cx * tileSize;
+ const yb = floorY - (i + 1) * layerWorldH;
+ const xl = cxW - lw / 2;
+ const [sx, sy] = worldToScreen(xl, yb);
+ const drawW = lw * zoom;
+ const drawH = layerWorldH * zoom;
+ const hue = (i * 47) % 360;
+ const seatL = L.seat != null ? L.seat : 1;
+ const heavyL = !!L.heavy;
+ if (towerHudLive && L.perfectGlowUntil != null) {
+ const ntGlow = performance.now();
+ if (ntGlow < L.perfectGlowUntil) {
+ drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil);
+ }
+ }
+ drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {});
+ }
+ if (towerHudLive && m.perfectStackX2Fx) {
+ const fxX2 = m.perfectStackX2Fx;
+ const ntX = performance.now();
+ if (ntX >= fxX2.until) {
+ m.perfectStackX2Fx = null;
+ } else {
+ const ti = fxX2.topLayerIndex;
+ if (ti >= hidLayers && ti >= 0 && ti < m.layers.length) {
+ const Ltop = m.layers[ti];
+ const lwT = (Ltop.w != null ? Ltop.w : m.initialWidthTiles) * tileSize * wMul;
+ const cxWT = Ltop.cx * tileSize;
+ const ybT = floorY - (ti + 1) * layerWorldH;
+ const xlT = cxWT - lwT / 2;
+ const [sxT, syT] = worldToScreen(xlT, ybT);
+ const drawWT = lwT * zoom;
+ const drawHT = layerWorldH * zoom;
+ const fxImX = ensureStackTowerPerfectFxImagesPlay();
+ if (fxImX.x2) {
+ drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until);
+ }
+ }
+ }
+ }
+ if (towerHudLive) {
+ drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY);
+ }
+ if (m.settling) {
+ const s = m.settling;
+ const u = Math.min(1, (performance.now() - s.t0) / s.dur);
+ const ue = u * u;
+ const rotSign = s.delta >= 0 ? 1 : -1;
+ const rotRad = ue * 1.22 * rotSign;
+ const yFall = ue * ue * tileSize * 6.5;
+ const xSlide = rotSign * ue * tileSize * 0.62;
+ const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
+ const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
+ const [cxs, cys] = worldToScreen(cxW, cyW);
+ const drawW = s.twWorld * zoom;
+ const drawH = s.stripH * zoom;
+ const hue = (m.layers.length * 47) % 360;
+ const seatS = s.actorSeat != null ? s.actorSeat : 1;
+ const heavyS = !!s.actorHeavy;
+ ctx.save();
+ ctx.translate(cxs, cys);
+ ctx.rotate(rotRad);
+ drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {});
+ ctx.restore();
+ } else {
+ const twWorld = m.widthTiles * tileSize * wMul;
+ const drawStripPx = stripH * zoom;
+ const cableTopY = isStackTowerMissionUiMapPlay()
+ ? (swingDrawY - tileSize * 6)
+ : Math.max(0, swingDrawY - tileSize * 6);
+ const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
+ let blockLeftWorld;
+ let blockTopWorld;
+ let fallTiltRad = 0;
+ if (stackFall) {
+ const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
+ const t = Math.min(1, tRaw);
+ const u = stackEaseDropPhys(t, stackFall.sloppyN);
+ blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
+ blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
+ fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
+ } else {
+ const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
+ blockLeftWorld = swingXW - twWorld / 2;
+ blockTopWorld = swingDrawY;
+ }
+ const cxMid = blockLeftWorld + twWorld / 2;
+ const [tcx, tcy] = worldToScreen(craneX, cableTopY);
+ const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
+ let slingBx = bcx;
+ let slingBy = bcy;
+ if (stackFall && stackFall.releaseAttachWorldX != null) {
+ const attY = stackFall.releaseAttachWorldY;
+ const p = worldToScreen(stackFall.releaseAttachWorldX, attY);
+ slingBx = p[0];
+ slingBy = p[1];
+ }
+ let ropeSx0 = tcx;
+ let ropeSy0 = tcy;
+ let ropeSx1 = slingBx;
+ let ropeSy1 = slingBy;
+ if (isStackTowerMissionUiMapPlay()) {
+ const chCv = ctx.canvas && ctx.canvas.height ? ctx.canvas.height : STACK_TOWER_FIXED_RENDER_H;
+ const topA = stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, chCv);
+ ropeSx0 = topA.x;
+ ropeSy0 = topA.y;
+ ropeSx1 = slingBx;
+ ropeSy1 = slingBy;
+ const t1 = Math.max(0, Math.min(1, STACK_TOWER_ROPE_DRAW_T1));
+ if (t1 < 1) {
+ ropeSx1 = ropeSx0 + (slingBx - ropeSx0) * t1;
+ ropeSy1 = ropeSy0 + (slingBy - ropeSy0) * t1;
+ }
+ }
+ drawStackSlingSegmentPlay(ctx, ropeSx0, ropeSy0, ropeSx1, ropeSy1, zoom);
+ const ropeAng = stackFall && stackFall.releaseRopeAng != null
+ ? stackFall.releaseRopeAng
+ : Math.atan2(bcy - ropeSy0, bcx - ropeSx0);
+ const swingTiltRad = ropeAng - Math.PI / 2;
+ const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
+ const seatDraw = stackFall && stackFall.actorSeat != null
+ ? stackFall.actorSeat
+ : (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat());
+ const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy;
+ const hueHint = (m.layers.length * 47) % 360;
+ ctx.save();
+ ctx.translate(bcx, bcy);
+ ctx.rotate(fallTiltRad + swingTiltRad);
+ drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint });
+ ctx.lineWidth = 1;
+ ctx.restore();
+ }
+ if (towerHudLive) {
+ drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay);
+ }
+ drawStackMissDebrisPlay(ctx, worldToScreen, zoom, floorY);
+ }
+
+ function applyGauntletTimingFromServer(payload) {
+ if (!payload || typeof payload !== 'object') return;
+ const tm = Number(payload.gauntletTickMs);
+ if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
+ const jt = Number(payload.gauntletJumpTicks);
+ if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
+ const tl = Number(payload.gauntletTimeLimitSec);
+ if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
+ if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
+ else {
+ const ge = Number(payload.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) {
+ gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
+ if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
+ gauntletLaneImageUrls = payload.gauntletLaneImageUrls
+ .filter((x) => typeof x === 'string')
+ .map((x) => normalizeGauntletAssetUrlForPlay(x))
+ .filter((x) => x)
+ .slice(0, 24);
+ gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
+ gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '');
+ ensureGauntletAssetImage(gauntletLaserTopUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
+ gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '');
+ ensureGauntletAssetImage(gauntletLaserBottomUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
+ gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '');
+ ensureGauntletAssetImage(gauntletLaserLineUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
+ gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
+ gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
+ const lw = Number(payload.gauntletLaserLineWidthPx);
+ if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
+ const sh = Number(payload.stackSwingHz);
+ if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
+ const raw = payload.stackBlockWidthTiles;
+ if (raw == null || raw === '') playStackBlockWidthTiles = null;
+ else {
+ const nw = Number(raw);
+ playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
+ ? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
+ : null;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerMissionTimeSec')) {
+ const st = Number(payload.stackTowerMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playStackTowerMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playStackTowerMissionTimeSec = 90;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTeamMissesMax')) {
+ const sm = Number(payload.stackTeamMissesMax);
+ if (Number.isFinite(sm)) {
+ playStackTeamMissesMax = Math.max(1, Math.min(20, Math.floor(sm)));
+ } else {
+ playStackTeamMissesMax = 3;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerProgressBlocks')) {
+ const pb = Number(payload.stackTowerProgressBlocks);
+ if (Number.isFinite(pb) && pb >= 1) {
+ playStackTowerProgressBlocks = Math.max(1, Math.min(500, Math.floor(pb)));
+ } else {
+ playStackTowerProgressBlocks = 50;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) {
+ playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) {
+ playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) {
+ const hp = Number(payload.stackHeavyBlockPercent);
+ if (Number.isFinite(hp)) {
+ playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp)));
+ } else {
+ playStackHeavyBlockPercent = 35;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
+ const jm = Number(payload.jumpSurviveJumpHeightMult);
+ if (Number.isFinite(jm)) {
+ playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) {
+ const jt = Number(payload.jumpSurviveMissionTimeSec);
+ if (Number.isFinite(jt) && jt > 0) {
+ playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt)));
+ } else {
+ playJumpSurviveMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTiles')
+ || Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')) {
+ const legacy = Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')
+ ? normalizeGauntletAssetUrlForPlay(typeof payload.jumpSurvivePlatformTileUrl === 'string' ? payload.jumpSurvivePlatformTileUrl : '')
+ : '';
+ const arr = Array.isArray(payload.jumpSurvivePlatformTiles) ? payload.jumpSurvivePlatformTiles : [];
+ const next = [];
+ for (let pi = 0; pi < 3; pi++) {
+ const o = arr[pi] && typeof arr[pi] === 'object' ? arr[pi] : {};
+ let url = normalizeGauntletAssetUrlForPlay(typeof o.url === 'string' ? o.url : '');
+ if (pi === 0 && !url && legacy) url = legacy;
+ let ww = Number(o.w);
+ let hh = Number(o.h);
+ if (!Number.isFinite(ww) || ww <= 0) ww = 0;
+ else ww = Math.max(8, Math.min(4096, Math.round(ww)));
+ if (!Number.isFinite(hh) || hh <= 0) hh = 0;
+ else hh = Math.max(8, Math.min(4096, Math.round(hh)));
+ next.push({ url, w: ww, h: hh });
+ if (url) ensureGauntletAssetImage(url);
+ }
+ playJumpSurvivePlatformTiles = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) {
+ const st = Number(payload.spaceShooterMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playSpaceShooterMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipImageUrls')) {
+ const a = payload.spaceShooterShipImageUrls;
+ const next = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ next[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipImageUrls = next;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidSpriteUrls')) {
+ const raw = payload.spaceShooterAsteroidSpriteUrls;
+ const next = [];
+ if (Array.isArray(raw)) {
+ for (let ai = 0; ai < raw.length && next.length < 32; ai++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof raw[ai] === 'string' ? raw[ai] : '');
+ if (u) next.push(u);
+ }
+ }
+ playSpaceShooterAsteroidSpriteUrls = next;
+ next.forEach((u) => {
+ if (u) ensureGauntletAssetImage(u);
+ });
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidExplodeFrameMs')) {
+ const ef = Number(payload.spaceShooterAsteroidExplodeFrameMs);
+ if (Number.isFinite(ef)) {
+ playSpaceShooterAsteroidExplodeFrameMs = Math.max(30, Math.min(500, Math.round(ef)));
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidIntervalMs')) {
+ const iv = Number(payload.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(iv) && iv >= 200) {
+ playSpaceShooterAsteroidIntervalMs = Math.min(10000, Math.floor(iv));
+ } else {
+ playSpaceShooterAsteroidIntervalMs = 1040;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipDamageOverlayUrls')) {
+ const rawD = payload.spaceShooterShipDamageOverlayUrls;
+ const nextD = ['', '', ''];
+ if (Array.isArray(rawD)) {
+ for (let di = 0; di < 3; di++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof rawD[di] === 'string' ? rawD[di] : '');
+ nextD[di] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playSpaceShooterShipDamageOverlayUrls = nextD;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) {
+ const st = Number(payload.balloonBossMissionTimeSec);
+ if (Number.isFinite(st) && st > 0) {
+ playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
+ } else {
+ playBalloonBossMissionTimeSec = 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) {
+ playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : ''
+ );
+ if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl);
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
+ const a = payload.balloonBossPlayerBalloonImageUrls;
+ const nextBb = ['', '', '', '', '', ''];
+ if (Array.isArray(a)) {
+ for (let si = 0; si < 6; si++) {
+ const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
+ nextBb[si] = u;
+ if (u) ensureGauntletAssetImage(u);
+ }
+ }
+ playBalloonBossPlayerBalloonImageUrls = nextBb;
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) {
+ playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay(
+ typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : ''
+ );
+ if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl);
+ }
+ if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
+ }
+
+ /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
+ const GAUNTLET_VIS_LERP = 0.42;
+
+ function gauntletObsSmoothstep(t) {
+ t = Math.max(0, Math.min(1, t));
+ return t * t * (3 - 2 * t);
+ }
+
+ function cloneGauntletObsSnap(arr) {
+ if (!Array.isArray(arr)) return [];
+ return arr.map((o) => {
+ if (!o || o.id == null) return null;
+ const row = { id: o.id, kind: o.kind, x: o.x, y: o.y };
+ if (o.kind === 'laser') {
+ if (o.y0 != null) row.y0 = o.y0;
+ if (o.y1 != null) row.y1 = o.y1;
+ }
+ return row;
+ }).filter(Boolean);
+ }
+
+ function gauntletLaserRowHitRange(ob, mapH) {
+ const h = Math.max(1, Math.floor(Number(mapH)) || 15);
+ let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0;
+ let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1;
+ y0 = Math.max(0, Math.min(h - 1, y0));
+ y1 = Math.max(0, Math.min(h - 1, y1));
+ if (y1 < y0) {
+ const t = y0;
+ y0 = y1;
+ y1 = t;
+ }
+ return { y0, y1 };
+ }
+
+ function gauntletLaserOverlapsPlayerRow(ob, py, mapH) {
+ const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH);
+ return py >= y0 && py <= y1;
+ }
+
+ /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
+ function gauntletLiftHeightNorm(air, jumpTicksMax) {
+ const jm = Math.max(4, jumpTicksMax || 16);
+ const a = Math.max(0, Math.min(jm, Number(air) || 0));
+ const t = a / jm;
+ /* อาร์คกระโดดนุ่มแบบ design (rn-jump): sine โค้งสมมาตร ease-in-out peak กลางอากาศ
+ เดิม: ขาขึ้น quadratic / ขาลง linear (อสมมาตร peak ~72% เวลา → ขาลงกระตุก) */
+ const p = Math.max(0, Math.min(1, 1 - t)); /* 0 = เริ่มกระโดด, 1 = แตะพื้น */
+ return Math.sin(Math.PI * p);
+ }
+
+ /* ===== Minigame-2: ชื่อ+คะแนนใต้ตัวละคร + เลข -10 เป็น DOM overlay (sync world→screen) แทนวาดบน canvas =====
+ เรียกทุกเฟรมจาก draw() (ส่ง worldToScreen/zDraw/tileSize เข้ามา) — container map buffer→display ด้วย transform:scale */
+ const gauntletDomLabelPool = new Map(); /* id -> .gnt-label (pool ไว้ reuse) */
+ const gauntletPenaltySeenById = new Map(); /* id -> ts penalty ล่าสุด (กัน spawn float ซ้ำ) */
+ function syncGauntletNameDom(worldToScreenFn, zDrawVal, tileSizePx) {
+ const ov = document.getElementById('gauntlet-name-overlay');
+ if (!ov) return;
+ const live = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live';
+ if (!live || typeof worldToScreenFn !== 'function') {
+ if (!ov.classList.contains('is-hidden')) {
+ ov.classList.add('is-hidden');
+ gauntletDomLabelPool.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); });
+ gauntletDomLabelPool.clear();
+ }
+ return;
+ }
+ ov.classList.remove('is-hidden');
+ const canvas = document.getElementById('game-canvas');
+ const stage = document.getElementById('play-canvas-stage');
+ if (!canvas || !stage) return;
+ const cr = canvas.getBoundingClientRect();
+ const srect = stage.getBoundingClientRect();
+ const dispScale = canvas.width > 0 ? cr.width / canvas.width : 1;
+ ov.style.left = Math.round(cr.left - srect.left) + 'px';
+ ov.style.top = Math.round(cr.top - srect.top) + 'px';
+ ov.style.width = canvas.width + 'px';
+ ov.style.height = canvas.height + 'px';
+ ov.style.transform = 'scale(' + dispScale + ')';
+ const fp = (typeof getCharacterFootprintWH === 'function') ? getCharacterFootprintWH(mapData) : { cw: 1, ch: 1 };
+ const cwv = fp.cw || 1, chv = fp.ch || 1;
+ function v(n) { const x = Number(n); return Number.isFinite(x) ? x : 0; }
+ const seen = new Set();
+ function place(id, ent, isMe) {
+ if (!ent) return;
+ if (typeof shouldGauntletCrownHeistSkipAvatarDrawPlay === 'function'
+ && shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMe, 0, 0)) return;
+ seen.add(id);
+ const air = isMe ? (meGauntletJumpVis || 0) : ((ent.gauntletJumpVis != null ? ent.gauntletJumpVis : ent.gauntletJumpTicks) || 0);
+ const liftWorldY = air > 0 ? gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSizePx * 0.52 : 0;
+ const cxWorld = (v(ent.x) + cwv * 0.5) * tileSizePx;
+ const cyBottomWorld = (v(ent.y) + chv) * tileSizePx - liftWorldY;
+ const scr = worldToScreenFn(cxWorld, cyBottomWorld);
+ const sx = scr[0], sy = scr[1];
+ /* ชื่อ/คะแนนใต้ตัวละคร: ตัดออกตาม mock — mock โชว์ชื่อ+คะแนนเฉพาะ "แถบคะแนนบน" เท่านั้น ไม่มีใต้ตัวละคร
+ (กันดูเหมือน 2 layer ทับกัน) — คงไว้เฉพาะ -10 penalty float ที่ mock มี */
+ const pen = Number(ent.gauntletCrownPenaltyFxUntil) || 0;
+ /* -10 float ต้องอยู่ "ใต้แถบคะแนนบน" เท่านั้น — กันผู้เล่นที่จุดเริ่ม (project ใกล้แถบคะแนน เช่น AFK) ทำ float โผล่ทับ SCORE label */
+ var sbEl = document.querySelector('.play-cyber-scoreboard');
+ var sbBottomBuf = sbEl ? ((sbEl.getBoundingClientRect().bottom - cr.top) / (dispScale || 1) + 6) : 0;
+ if (pen > (gauntletPenaltySeenById.get(id) || 0) && (sy - 28) > sbBottomBuf) {
+ gauntletPenaltySeenById.set(id, pen);
+ /* -10 float = design asset score-.png (รูป "-10" สำเร็จรูป) ไม่ใช่ text สไตล์เอง */
+ const pf = document.createElement('img');
+ pf.className = 'gnt-pen';
+ pf.src = GAUNTLET_CROWN_SCORE_POP_URL;
+ pf.alt = '-10';
+ pf.decoding = 'async';
+ pf.style.left = Math.round(sx) + 'px';
+ pf.style.top = Math.round(sy - 28) + 'px';
+ ov.appendChild(pf);
+ setTimeout(function () { if (pf.parentNode) pf.parentNode.removeChild(pf); }, 900);
+ } else if (pen > (gauntletPenaltySeenById.get(id) || 0)) {
+ /* อยู่บริเวณแถบคะแนน — บันทึกว่าเห็นแล้ว ไม่ต้องสร้าง float (กัน spawn ซ้ำตอนเลื่อนลงมา) */
+ gauntletPenaltySeenById.set(id, pen);
+ }
+ }
+ if (others && typeof others.forEach === 'function') others.forEach((o, id) => place(id, o, false));
+ place('__me', me, true);
+ }
+
+ function getGauntletObsDrawPositionsAt(nowMs) {
+ if (!gauntletObsRenderNext.length) return [];
+ const elapsed = nowMs - gauntletObsBlendT0;
+ const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs);
+ const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o]));
+ const out = [];
+ for (let i = 0; i < gauntletObsRenderNext.length; i++) {
+ const n = gauntletObsRenderNext[i];
+ if (!n) continue;
+ const p = prevMap.get(n.id);
+ let drawX = n.x;
+ if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha;
+ const row = { id: n.id, kind: n.kind, drawX, y: n.y };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ out.push(row);
+ }
+ return out;
+ }
+
+ function pushGauntletObsRenderFrame(newObs) {
+ const now = performance.now();
+ const next = cloneGauntletObsSnap(newObs);
+ if (!gauntletObsRenderNext.length) {
+ gauntletObsRenderPrev = next.slice();
+ gauntletObsRenderNext = next.slice();
+ } else {
+ const curDraw = getGauntletObsDrawPositionsAt(now);
+ const curMap = new Map(curDraw.map((o) => [o.id, o.drawX]));
+ gauntletObsRenderPrev = next.map((n) => {
+ const row = {
+ id: n.id,
+ kind: n.kind,
+ x: curMap.has(n.id) ? curMap.get(n.id) : n.x,
+ y: n.y,
+ };
+ if (n.kind === 'laser') {
+ if (n.y0 != null) row.y0 = n.y0;
+ if (n.y1 != null) row.y1 = n.y1;
+ }
+ return row;
+ });
+ gauntletObsRenderNext = next;
+ }
+ gauntletObsBlendT0 = now;
+ }
+
+ function lerpGauntletEntityPos(x, y, tx, ty) {
+ let nx = x;
+ let ny = y;
+ if (tx != null && Number.isFinite(tx)) {
+ nx += (tx - nx) * GAUNTLET_VIS_LERP;
+ if (Math.abs(tx - nx) < 0.02) nx = tx;
+ }
+ if (ty != null && Number.isFinite(ty)) {
+ ny += (ty - ny) * GAUNTLET_VIS_LERP;
+ if (Math.abs(ty - ny) < 0.02) ny = ty;
+ }
+ return { nx, ny };
+ }
+
+ /**
+ * บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท)
+ * — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป
+ */
+ function maybePreviewBotGauntletJump(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ if ((o.gauntletJumpTicks || 0) > 0) return;
+ const { ch: gauntletCh } = getCharacterFootprintWH(mapData);
+ let sameCell = false;
+ let incomingCol = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const obs = obstacles[i];
+ if (!obs) continue;
+ if (obs.kind === 'lane' && typeof obs.y === 'number') {
+ if (obs.x === px && obs.y >= py && obs.y < py + gauntletCh) sameCell = true;
+ if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true;
+ }
+ if (obs.kind === 'laser' && typeof obs.x === 'number') {
+ if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true;
+ if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) incomingCol = true;
+ }
+ }
+ const tier = o.botTier || 'avg';
+ const roll = Math.random();
+ const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86;
+ const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72;
+ if (sameCell && roll < pSame) {
+ o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ return;
+ }
+ if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
+ }
+
+ /** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */
+ function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) {
+ if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
+ let px = Math.floor(Number(o.x)) || 0;
+ let py = Math.floor(Number(o.y)) || 0;
+ px = Math.max(0, Math.min(w - 1, px));
+ py = Math.max(0, Math.min(h - 1, py));
+ const air = (o.gauntletJumpTicks || 0) > 0;
+ const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData);
+ const crown = isGauntletCrownHeistMapPlay();
+ let advanceX = false;
+ let hitBack = false;
+ for (let i = 0; i < obstacles.length; i++) {
+ const ob = obstacles[i];
+ if (!ob) continue;
+ if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y >= py && ob.y < py + gauntletCh2) {
+ if (air) advanceX = true;
+ else hitBack = true;
+ }
+ if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) {
+ if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) {
+ /* เลเซอร์ไม่ครอบแถวนี้ */
+ } else if (air) {
+ advanceX = true;
+ } else {
+ hitBack = true;
+ }
+ }
+ }
+ if (advanceX) {
+ px = Math.min(w - 2, px + 1);
+ o.gauntletJumpTicks = 0;
+ if (!crown) {
+ o.gauntletScore = (o.gauntletScore || 0) + 1;
+ }
+ } else if (hitBack) {
+ if (crown) {
+ if (px <= 0) {
+ o.gauntletEliminated = true;
+ o.gauntletScore = 0;
+ } else {
+ o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10);
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ px = Math.max(0, px - 1);
+ }
+ } else {
+ px = Math.max(0, px - 1);
+ }
+ }
+ if (crown && mapData) {
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx != null && Number.isFinite(mx)) {
+ const maxPx = Math.floor(mx + 1e-6);
+ px = Math.min(px, maxPx);
+ }
+ }
+ if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
+ o.tx = px;
+ o.ty = py;
+ }
+
+ function applyGauntletPreviewBotsAfterSync() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ if (isGauntletCrownHeistMapPlay() && gauntletCrownRunwayStripFrozenPlay()) return;
+ const w = mapData.width || 20;
+ const h = mapData.height || 15;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o) return;
+ maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
+ applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
+ });
+ }
+
+ /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
+ function emitGauntletPreviewRowsToServer() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const h = mapData.height || 15;
+ const ysSet = new Set();
+ const addY = (v) => {
+ const fy = Math.floor(Number(v));
+ if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
+ };
+ addY(me.y);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) addY(o.y);
+ });
+ socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
+ }
+
+ /** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */
+ function hideConflictingOverlaysForGauntletCrown() {
+ hideStackTowerResultFlashDomOnlyPlay();
+ hideQuizCarryPregameOverlay();
+ quizCarryPregameActive = false;
+ const hideIds = [
+ 'quiz-game-overlay',
+ 'quiz-carry-mission-overlay',
+ 'gauntlet-ended-overlay',
+ 'quiz-battle-mcq-overlay',
+ 'quiz-carry-embed-countdown',
+ 'gauntlet-crown-countdown',
+ 'quiz-carry-embed-q-strip',
+ 'play-quiz-scoreboard',
+ 'play-quiz-feedback',
+ ];
+ for (let i = 0; i < hideIds.length; i++) {
+ const el = document.getElementById(hideIds[i]);
+ if (el) el.classList.add('is-hidden');
+ }
+ const gcm = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcm) gcm.classList.add('is-hidden');
+ hideStackTowerHowtoRulesDom();
+ const gchHowto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (gchHowto) gchHowto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
+ }
+
+ function gcmRankBadgeUrl(rank) {
+ const r = Math.floor(Number(rank)) || 99;
+ if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png';
+ if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png';
+ if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png';
+ return BASE + '/img/gauntlet-assets/result-txt-2.png';
+ }
+
+ /** สรุปภารกิจแบบ mockup — ต่อมินิเกม mission map (Minigame 1/4–7 + Last Light mno9kb07) */
+ function isGcmMissionMockSummaryPlay(mission) {
+ if (!mission || !mission.uiSkin) {
+ return isGauntletCrownHeistMapPlay();
+ }
+ if (mission.uiSkin === 'gauntlet_crown') return isGauntletCrownHeistMapPlay();
+ if (mission.uiSkin === 'question_mission') return isQuizQuestionMissionUiMapPlay();
+ if (mission.uiSkin === 'jumper') return isJumpSurviveMissionUiMapPlay();
+ if (mission.uiSkin === 'violent_crime') return isSpaceShooterMissionUiMapPlay();
+ if (mission.uiSkin === 'mega_virus') return isMegaVirusMissionShellMapPlay();
+ if (mission.uiSkin === 'stack_tower') return isStackTowerMissionUiMapPlay();
+ if (mission.uiSkin === 'quiz_carry') return quizCarryEmbedMissionFlowActive() || isQuizCarry();
+ return false;
+ }
+
+ function gcmMissionMockAssetUrl(mission, file) {
+ if (!mission) {
+ return isGauntletCrownHeistMapPlay()
+ ? (BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, ''))
+ : questionMissionAssetUrl(file);
+ }
+ const skin = mission.uiSkin;
+ if (skin === 'gauntlet_crown') return BASE + '/img/gauntlet-assets/' + String(file || '').replace(/^\//, '');
+ if (skin === 'jumper') return jumperAssetUrl(file);
+ if (skin === 'violent_crime') return violentCrimeAssetUrl(file);
+ if (skin === 'mega_virus') return megaVirusAssetUrl(file);
+ if (skin === 'stack_tower') return stackTowerAssetUrl(file);
+ if (skin === 'quiz_carry') return BASE + '/img/quiz-carry/' + String(file || '').replace(/^\//, '');
+ return questionMissionAssetUrl(file);
+ }
+
+ function resolveGauntletCrownMissionUiSkinPlay(mission) {
+ if (mission && mission.uiSkin) return mission.uiSkin;
+ if (isGauntletCrownHeistMapPlay()) return 'gauntlet_crown';
+ return mission && mission.uiSkin;
+ }
+
+ function setGauntletCrownMg2CountdownGraphic(numEl, textEl, n) {
+ const d = Math.max(1, Math.min(3, n));
+ if (textEl) {
+ textEl.textContent = String(d);
+ textEl.setAttribute('aria-label', String(d));
+ }
+ if (numEl) {
+ numEl.removeAttribute('src');
+ numEl.alt = '';
+ }
+ }
+
+ function syncGauntletCrownCountdownMg2ModePlay(cd) {
+ if (!cd) return;
+ const mg2 = isGauntletCrownHeistMapPlay();
+ cd.classList.toggle('gcc-mg2-mock', mg2);
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ if (numEl) {
+ numEl.classList.toggle('gcc-num--img', !mg2);
+ numEl.classList.toggle('is-hidden', !!mg2);
+ numEl.setAttribute('aria-hidden', mg2 ? 'true' : 'false');
+ }
+ if (textEl) {
+ textEl.classList.toggle('is-hidden', !mg2);
+ textEl.setAttribute('aria-hidden', mg2 ? 'false' : 'true');
+ }
+ }
+
+ function gauntletCrownPregameReadyNumerator() {
+ let n = quizCarryPregameBotCount();
+ quizCarryPregameHumanIds().forEach((id) => {
+ if (gauntletCrownLobbyReadyMap[id]) n++;
+ });
+ return n;
+ }
+
+ function gauntletCrownSyncGuestReadyIfNeeded() {
+ const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto';
+ const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto';
+ const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto';
+ const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto';
+ if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return;
+ if (myId == null || isMePlayHost()) return;
+ const sid = String(myId);
+ if (gauntletCrownLobbyReadyMap[sid]) return;
+ gauntletCrownLobbyReadyMap[sid] = true;
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true });
+ }
+
+ /** Minigame-2 mno9kb07 — HOW TO PLAY ใช้ layout mock 01-howto (canvas = mg-bg) */
+ function gauntletCrownHowtoUsesMg2LayoutPlay() {
+ return !!isGauntletCrownHeistMapPlay();
+ }
+ function getGauntletCrownHowtoStatusEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('gauntlet-crown-howto-status-mg2');
+ }
+ return document.getElementById('gauntlet-crown-howto-status');
+ }
+ function getGauntletCrownHowtoBtnEl() {
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ return document.getElementById('btn-gch-ready-mg2');
+ }
+ return document.getElementById('btn-gch-ready');
+ }
+ function setPlayMg2HowtoLivePlay(on) {
+ document.documentElement.classList.toggle('play-mg2-howto-live', !!on);
+ }
+ function clearGchMg2OverlayBoundsPlay(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ ['position', 'left', 'top', 'width', 'height', 'right', 'bottom', 'transform'].forEach((p) => {
+ ov.style.removeProperty(p);
+ });
+ }
+ function dismissGauntletCrownHowtoOverlayPlay() {
+ const howto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (howto) {
+ howto.classList.add('is-hidden');
+ setPlayMg2HowtoLivePlay(false);
+ clearGchMg2OverlayBoundsPlay(howto);
+ }
+ gauntletCrownHowtoVisible = false;
+ }
+
+ /** ภารกิจคำถาม mng8a80o — Ready Status / ปุ่ม แบบเดียวกับ Mega Virus (gauntlet-crown-lobby-sync) */
+ function updateQuizQuestionMissionHowtoHud() {
+ if (!isQuizQuestionMissionUiMapPlay() || quizQuestionMissionPhase !== 'howto') return;
+ const st = document.getElementById('gauntlet-crown-howto-status');
+ const btn = document.getElementById('btn-gch-ready');
+ if (!st || !btn) return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function updateGauntletCrownHowtoHud() {
+ const st = getGauntletCrownHowtoStatusEl();
+ const btn = getGauntletCrownHowtoBtnEl();
+ if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const tot = Math.max(1, quizCarryPregameTotalPlayers());
+ const num = gauntletCrownPregameReadyNumerator();
+ if (st) {
+ st.classList.remove('is-hidden');
+ st.textContent = 'Ready Status : ' + num + '/' + tot;
+ }
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ btn.classList.toggle('is-start-phase', humansReady);
+ if (gauntletCrownHowtoUsesMg2LayoutPlay()) {
+ const btnImg = document.getElementById('btn-gch-ready-mg2-img');
+ if (btnImg) {
+ btnImg.src = humansReady
+ ? (missionMockHudAssetUrl('btn-start.png') || (BASE + '/img/gauntlet-assets/btn-start.png'))
+ : (missionMockHudAssetUrl('btn-ready.png') || (BASE + '/img/gauntlet-assets/btn-ready.png'));
+ btnImg.alt = humansReady ? 'START' : 'READY';
+ }
+ }
+ btn.classList.toggle('is-read-only', !isMePlayHost());
+ btn.disabled = !isMePlayHost();
+ btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
+ btn.title = isMePlayHost()
+ ? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
+ : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
+ }
+
+ function beginGauntletCrownCountdownThenRun() {
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'countdown';
+ if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(false);
+ }
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ dismissGauntletCrownHowtoOverlayPlay();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ const numEl = document.getElementById('gauntlet-crown-countdown-num');
+ function applyMegaBalloonLiveStartPlay() {
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastTickMs = performance.now();
+ balloonBossGameEnded = false;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossBossFireAcc = 0;
+ balloonBossScorePopups = [];
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (mapData) applyBalloonBossSpawnLayoutPlay();
+ }
+ const runFinish = () => {
+ if (cd) cd.classList.add('is-hidden');
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ if (socket && socket.connected) {
+ socket.emit('gauntlet-crown-begin-run', (res) => {
+ if (res && res.ok) {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ });
+ } else {
+ gauntletCrownPregamePhase = 'live';
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
+ }
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ const textEl = document.getElementById('gauntlet-crown-countdown-text');
+ const mg2Cd = isGauntletCrownHeistMapPlay();
+ cd.classList.remove('is-hidden');
+ let n = 3;
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ const step = () => {
+ n--;
+ if (n > 0) {
+ if (mg2Cd) setGauntletCrownMg2CountdownGraphic(numEl, textEl, n);
+ else setCountdown321QuestionAssetGraphic(numEl, n);
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ } else {
+ gauntletCrownCountdownTimer = null;
+ runFinish();
+ }
+ };
+ gauntletCrownCountdownTimer = setTimeout(step, 1000);
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ function showGauntletCrownHowtoOverlay() {
+ if (!usesCrownLobbyShellPlay()) return;
+ hideConflictingOverlaysForGauntletCrown();
+ if (isGauntletCrownHeistMapPlay()) applyGauntletCrownHeistMissionPanelImages();
+ else if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages();
+ const cd = document.getElementById('gauntlet-crown-countdown');
+ if (cd) {
+ cd.classList.add('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ }
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ gauntletCrownPregamePhase = 'howto';
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ const ov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ gauntletCrownHowtoVisible = true;
+ ov.classList.remove('is-hidden');
+ if (isGauntletCrownHeistMapPlay()) {
+ setPlayMg2HowtoLivePlay(true);
+ updateGchTfMockScale(ov);
+ trySnapGauntletCrownRunwayScrollToSpawnsPlay();
+ }
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { syncPlayCyberHud(); } catch (_sh) { /* ignore */ }
+ }
+
+ /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */
+ function gauntletCrownEmbedMissionAnyOk(mission) {
+ if (!mission) return false;
+ if (mission.uiSkin === 'violent_crime') {
+ const sc = Number(mission.survivorCount);
+ if (Number.isFinite(sc) && sc <= 0) return false;
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'jumper') {
+ const sc = Number(mission.survivorCount);
+ return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F');
+ }
+ if (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ if (mission.uiSkin === 'mega_virus') {
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+ const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
+ return g !== 'F';
+ }
+
+ function gauntletCrownRankBonusLocal(rankOrdinal) {
+ if (rankOrdinal === 1) return 100;
+ if (rankOrdinal === 2) return 80;
+ if (rankOrdinal === 3) return 60;
+ return 0;
+ }
+
+ function gauntletCrownRollRewardCardLocal(grade) {
+ const r = Math.random();
+ if (grade === 'A') {
+ if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (grade === 'B') {
+ if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
+ if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+ if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
+ return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
+ }
+
+ /** พรีวิว: สรุปภารกิจ Last Light หลังรันเวย์ครบ — ให้ gauntletCrownBuildDisplayMission รวมบอท */
+ function gauntletCrownHeistBuildLocalCrownMissionPlay() {
+ const baseRows = [];
+ if (myId != null) {
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+ others.forEach((o, id) => {
+ if (!o || isPreviewBotId(id)) return;
+ baseRows.push({
+ id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const bs = Math.max(0, Number(row.baseScore) | 0);
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: bs,
+ eliminated: !!row.eliminated,
+ rank: pos,
+ rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
+ rankBonus,
+ finalScore: bs + rankBonus,
+ };
+ });
+ const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const survivorCount = baseRows.filter((r) => !r.eliminated).length;
+ let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ if (survivorCount <= 0) grade = 'F';
+ const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
+ return {
+ uiSkin: 'gauntlet_crown',
+ ranked,
+ totalSum,
+ averageScore,
+ grade,
+ rewardCard,
+ totalParts: ranked.map((r) => r.finalScore),
+ participantCount: baseRows.length,
+ survivorCount,
+ };
+ }
+
+ /**
+ * Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์
+ * — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row)
+ */
+ function gauntletCrownBuildDisplayMission(mission) {
+ if (!mission || !Array.isArray(mission.ranked)) return mission;
+ if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission;
+ if (!(playBotsEnabled() && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission;
+
+ const seen = new Set();
+ const baseRows = [];
+ mission.ranked.forEach((r) => {
+ if (!r) return;
+ const sid = String(r.id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: r.id,
+ nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น',
+ characterId: r.characterId ?? null,
+ baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0),
+ eliminated: !!r.eliminated,
+ });
+ });
+ others.forEach((o, id) => {
+ if (!o || !isPreviewBotId(id)) return;
+ const sid = String(id);
+ if (seen.has(sid)) return;
+ seen.add(sid);
+ baseRows.push({
+ id: id,
+ nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
+ characterId: o.characterId ?? null,
+ baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
+ eliminated: !!o.gauntletEliminated,
+ });
+ });
+ /* รวมตัวเรา (human) ถ้ายังไม่อยู่ — บาง mission (preview/จาก server) มีแต่บอท ทำให้ตัวเราตกหายจาก summary */
+ if (myId != null && !seen.has(String(myId))) {
+ seen.add(String(myId));
+ baseRows.push({
+ id: myId,
+ nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
+ characterId: me.characterId ?? null,
+ baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
+ eliminated: !!me.gauntletEliminated,
+ });
+ }
+
+ baseRows.sort((a, b) => {
+ if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
+ const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
+ if (nick !== 0) return nick;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const top = baseRows.slice(0, 6); /* Last Light รองรับ 6 ผู้เล่น — summary ต้องครบ 6 รวมตัวเรา (เดิม 5 ตัดคนคะแนนต่ำสุด/ตายออก) */
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const rankBonus = gauntletCrownRankBonusLocal(pos);
+ const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos);
+ const finalScore = row.baseScore + rankBonus;
+ return {
+ id: row.id,
+ nickname: row.nickname,
+ characterId: row.characterId,
+ baseScore: row.baseScore,
+ eliminated: row.eliminated,
+ rank: pos,
+ rankLabel: rankLabel,
+ rankBonus: rankBonus,
+ finalScore: finalScore,
+ };
+ });
+
+ const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0);
+ const n = ranked.length || 1;
+ const averageScore = Math.floor(totalSum / n);
+ const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
+ const rewardCard = gauntletCrownRollRewardCardLocal(grade);
+ return {
+ ranked: ranked,
+ totalSum: totalSum,
+ averageScore: averageScore,
+ grade: grade,
+ rewardCard: rewardCard,
+ totalParts: ranked.map(function (r) { return r.finalScore; }),
+ };
+ }
+
+ /* ===== ชุดคำถามพิเศษ (รับการ์ดพิเศษ) — โมดูลกลาง ใช้ได้หลายเกม =====
+ * จุดต่อเข้าเกมจริง (trigger) จะ wire ภายหลังตามที่ผู้ใช้ระบุ
+ * วิธีใช้: SpecialQuiz.fetchSettings() -> getSet(level,caseId) -> pickQuestions(set,n)
+ * เล่นจบแล้ว awardCard(set, correct, total) -> set mission.specialCardAwarded = card
+ */
+ let __specialQuizSettingsCache = null;
+ let __specialQuizFetchPromise = null;
+
+ function specialQuizFetchSettings() {
+ if (__specialQuizSettingsCache) return Promise.resolve(__specialQuizSettingsCache);
+ if (__specialQuizFetchPromise) return __specialQuizFetchPromise;
+ __specialQuizFetchPromise = fetch(BASE + '/api/quiz-settings', { cache: 'no-store' })
+ .then(function (r) { return r.json(); })
+ .then(function (j) { __specialQuizSettingsCache = j || {}; return __specialQuizSettingsCache; })
+ .catch(function () { __specialQuizFetchPromise = null; return {}; });
+ return __specialQuizFetchPromise;
+ }
+
+ function specialQuizCurrentLevelCase() {
+ const meta = window.__detectiveLobbyMeta || {};
+ let level = Number(meta.level);
+ let caseId = Number(meta.caseId);
+ if (!(level >= 1 && level <= 5)) {
+ const lp = Number(params.get('lobbyLevel'));
+ if (lp >= 1 && lp <= 5) level = lp;
+ }
+ if (!(caseId >= 1 && caseId <= 3)) {
+ const cp = Number(params.get('case') || params.get('caseId'));
+ if (cp >= 1 && cp <= 3) caseId = cp;
+ }
+ return {
+ level: (level >= 1 && level <= 5) ? level : 1,
+ caseId: (caseId >= 1 && caseId <= 3) ? caseId : 1,
+ };
+ }
+
+ function specialQuizGetSet(settings, level, caseId) {
+ const sets = (settings && settings.specialQuizSets) || {};
+ const key = 'L' + level + '_C' + caseId;
+ return sets[key] || sets.L1_C1 || { questions: [], specialCard: {} };
+ }
+
+ function specialQuizPickQuestions(set, count) {
+ const qs = (set && Array.isArray(set.questions)) ? set.questions.slice() : [];
+ for (let i = qs.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const t = qs[i]; qs[i] = qs[j]; qs[j] = t;
+ }
+ let n = Number(count);
+ if (!(n >= 1)) n = qs.length;
+ return qs.slice(0, Math.min(n, qs.length));
+ }
+
+ /** กติกาได้การ์ด: ตอบถูก >= ครึ่งหนึ่ง และชุดนั้นตั้งการ์ดไว้ — คืน card หรือ null */
+ function specialQuizAwardCard(set, correctCount, totalCount) {
+ const card = (set && set.specialCard) || null;
+ if (!card || !(card.th || card.en || card.imageUrl)) return null;
+ const total = Number(totalCount) || 0;
+ const correct = Number(correctCount) || 0;
+ const passNeed = total > 0 ? Math.ceil(total / 2) : 1;
+ if (correct < passNeed) return null;
+ return card;
+ }
+
+ /** วาดการ์ดพิเศษลงในโซนโบนัสของสรุปผล (#gcm-bonus / .sm-bonus-right) */
+ function renderSpecialCardInBonus(bonusEl, card) {
+ if (!bonusEl || !card || !(card.th || card.en || card.imageUrl || card.cardId)) return;
+ const imgUrl = card.imageUrl || (card.cardId >= 1 && card.cardId <= 7
+ ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (!imgUrl) return;
+ const isPresetAsset = imgUrl.indexOf('/img/special-cards/') >= 0 || imgUrl.indexOf('/trycss/10-Game-1-result/') >= 0;
+ if (bonusEl.classList && bonusEl.classList.contains('gcm-bonus-mg2-right')) {
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ img.decoding = 'async';
+ bonusEl.appendChild(img);
+ return;
+ }
+ const wrap = document.createElement('div');
+ wrap.className = isPresetAsset
+ ? 'sm-bonus-right gcm-special-card-preset'
+ : 'gcm-special-card sm-bonus-special rarity-' + (card.rarity || 'rare');
+ const img = document.createElement('img');
+ img.className = isPresetAsset ? 'bail-card gcm-special-card-img' : 'gcm-special-card-img';
+ img.src = imgUrl;
+ img.alt = card.th || card.en || '';
+ wrap.appendChild(img);
+ if (!isPresetAsset) {
+ const cap = document.createElement('div');
+ cap.className = 'gcm-special-card-cap';
+ const strong = document.createElement('strong');
+ strong.textContent = card.th || '';
+ const span = document.createElement('span');
+ span.textContent = card.en || '';
+ cap.appendChild(strong);
+ cap.appendChild(span);
+ wrap.appendChild(cap);
+ }
+ bonusEl.appendChild(wrap);
+ }
+
+ window.SpecialQuiz = {
+ fetchSettings: specialQuizFetchSettings,
+ currentLevelCase: specialQuizCurrentLevelCase,
+ getSet: specialQuizGetSet,
+ pickQuestions: specialQuizPickQuestions,
+ awardCard: specialQuizAwardCard,
+ renderCardInBonus: renderSpecialCardInBonus,
+ };
+
+ /* ===== Special Quiz ในเกม — ไอคอนในฉาก (เดินชน) + overlay คำถาม multiplayer =====
+ * server เป็นผู้ตัดสิน: สุ่มไอคอน 1/3, เปิดคำถามให้ทุกคนพร้อมกัน, ตอบถูกทุกคนทุกข้อ -> ได้การ์ด
+ */
+ const SPECIAL_QUIZ_CHOICE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ /** Test Mode (Admin) เก็บใน localStorage — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
+ function __isTestModeOnPlay() {
+ try { return localStorage.getItem('justiceTestMode') === '1'; } catch (e) { return false; }
+ }
+ let specialQuizIcon = null; // { iconType, x, y } (พิกัด tile-center)
+ let specialQuizIconScreenRect = null; // { x, y, r } พิกัดบนจอ (ไว้คลิก/แตะไอคอน)
+ let specialQuizCollideSent = false;
+ let specialQuizCollideCooldownUntil = 0;
+ let specialQuizAwardedCard = null; // การ์ดที่ได้รอบนี้ (จาก special-quiz-ended)
+ let specialQuizActiveQuestion = null; // { index, total, choices, endsAt, iconType }
+ let specialQuizAnswered = false;
+ let specialQuizTimerRaf = 0;
+ const specialQuizIconImgs = {};
+ /** หยุดเกมทุกอย่างระหว่างตอบคำถามพิเศษ แล้ว resume หลังจบ */
+ let specialQuizFreeze = false;
+ let specialQuizFreezeStartMs = 0;
+ let specialQuizRoster = []; // [{ id, nickname, isBot }]
+ let specialQuizAnswerStatus = {}; // id -> true (ตอบแล้ว)
+
+ let specialQuizFreezeLastTickMs = 0;
+
+ function beginSpecialQuizFreeze() {
+ if (specialQuizFreeze) return;
+ specialQuizFreeze = true;
+ specialQuizFreezeStartMs = Date.now();
+ specialQuizFreezeLastTickMs = Date.now();
+ try { for (const k in keys) keys[k] = false; } catch (e) { /* ignore */ }
+ try { playPath = []; } catch (e) { /* ignore */ }
+ if (me) me.isWalking = false;
+ }
+
+ /** เลื่อน deadline/anchor เวลาทุกตัวไปข้างหน้าเท่า delta — ใช้เรียกทุกเฟรมระหว่าง freeze
+ เพื่อให้ "เวลาที่แสดง" หยุดนิ่ง (ไม่ใช่แค่ชดเชยตอนจบ) */
+ function shiftSpecialQuizFrozenTimers(deltaMs) {
+ if (!(deltaMs > 0)) return;
+ const shift = (v) => (typeof v === 'number' && v > 0) ? v + deltaMs : v;
+ try { playQuizPhaseEndsAt = shift(playQuizPhaseEndsAt); } catch (e) {}
+ try { quizCarryEmbedCountdownStartAt = shift(quizCarryEmbedCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedCountdownEndAt = shift(quizCarryEmbedCountdownEndAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownStartAt = shift(quizCarryEmbedPreOptionCountdownStartAt); } catch (e) {}
+ try { quizCarryEmbedPreOptionCountdownEndAt = shift(quizCarryEmbedPreOptionCountdownEndAt); } catch (e) {}
+ try { quizCarryOptionRevealAt = shift(quizCarryOptionRevealAt); } catch (e) {}
+ try { quizCarryAnswerCloseAt = shift(quizCarryAnswerCloseAt); } catch (e) {}
+ try { if (typeof jumpSurviveSessionStartMs === 'number' && jumpSurviveSessionStartMs > 0) jumpSurviveSessionStartMs += deltaMs; } catch (e) {}
+ try { if (typeof spaceShooterSessionStartMs === 'number' && spaceShooterSessionStartMs > 0) spaceShooterSessionStartMs += deltaMs; } catch (e) {}
+ }
+
+ /** เรียกทุกเฟรมระหว่าง freeze — ดันเวลาไปข้างหน้าเท่าที่ผ่านไปจริง ทำให้ countdown หยุดนิ่ง */
+ function tickSpecialQuizFreezeShift() {
+ if (!specialQuizFreeze) return;
+ const now = Date.now();
+ shiftSpecialQuizFrozenTimers(now - specialQuizFreezeLastTickMs);
+ specialQuizFreezeLastTickMs = now;
+ }
+
+ function endSpecialQuizFreeze() {
+ if (!specialQuizFreeze) return;
+ /* ชดเชย delta ที่เหลือตั้งแต่เฟรมสุดท้ายจนถึงตอนนี้ */
+ shiftSpecialQuizFrozenTimers(Date.now() - specialQuizFreezeLastTickMs);
+ specialQuizFreeze = false;
+ specialQuizFreezeStartMs = 0;
+ specialQuizFreezeLastTickMs = 0;
+ }
+
+ function specialQuizIconUrl(type) {
+ return '/Game/img/special-quiz/icon-' + (type === 'police' ? 'police' : 'lawyer') + '.png';
+ }
+ function specialQuizGetIconImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizIconImgs[key]) return specialQuizIconImgs[key];
+ const img = new Image();
+ img.src = specialQuizIconUrl(key);
+ specialQuizIconImgs[key] = img;
+ return img;
+ }
+ const specialQuizGlowImgs = {};
+ /** แสง glow (08-event light-*) วางหลังไอคอน — ให้ดูเป็นของเก็บเรืองแสงบนเส้นทาง */
+ function specialQuizGetGlowImg(type) {
+ const key = type === 'police' ? 'police' : 'lawyer';
+ if (specialQuizGlowImgs[key]) return specialQuizGlowImgs[key];
+ const img = new Image();
+ img.src = '/Game/img/special-quiz/light-' + key + '.png';
+ specialQuizGlowImgs[key] = img;
+ return img;
+ }
+ function setSpecialQuizIconFromServer(payload) {
+ if (!payload || payload.x == null || payload.y == null) { specialQuizIcon = null; return; }
+ specialQuizIcon = {
+ iconType: payload.iconType === 'police' ? 'police' : 'lawyer',
+ x: Number(payload.x),
+ y: Number(payload.y),
+ };
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ specialQuizGetIconImg(specialQuizIcon.iconType);
+ }
+ function clearSpecialQuizIcon() {
+ specialQuizIcon = null;
+ specialQuizIconScreenRect = null;
+ specialQuizCollideSent = false;
+ specialQuizCollideCooldownUntil = 0;
+ }
+
+ /** ส่ง collide ไป server เพื่อเปิดคำถาม — ถ้า server ปฏิเสธ (เช่น race) ให้ลองใหม่ได้หลัง cooldown */
+ function sendSpecialQuizCollide() {
+ if (specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (Date.now() < specialQuizCollideCooldownUntil) return;
+ specialQuizCollideSent = true;
+ specialQuizCollideCooldownUntil = Date.now() + 800;
+ try {
+ socket.emit('special-quiz-collide', {}, function (res) {
+ if (!(res && res.ok) && !specialQuizActiveQuestion) specialQuizCollideSent = false;
+ });
+ } catch (e) { specialQuizCollideSent = false; }
+ }
+
+ /** กด/แตะไอคอนควิซเพื่อเปิดคำถาม (ใช้ได้ทุกมินิเกม รวม space shooter ที่เดินไปแตะไม่ได้) */
+ function trySpecialQuizClickAt(clientX, clientY) {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return false;
+ if (!specialQuizIconScreenRect || !canvas) return false;
+ const r = canvas.getBoundingClientRect();
+ if (!r.width || !r.height) return false;
+ const px = (clientX - r.left) * (canvas.width / r.width);
+ const py = (clientY - r.top) * (canvas.height / r.height);
+ const dx = px - specialQuizIconScreenRect.x;
+ const dy = py - specialQuizIconScreenRect.y;
+ /* เผื่อระยะกดให้กว้างขึ้น — แตะใกล้ไอคอนก็เปิดได้ */
+ const hitR = specialQuizIconScreenRect.r * 1.3;
+ if (dx * dx + dy * dy <= hitR * hitR) {
+ sendSpecialQuizCollide();
+ return true;
+ }
+ return false;
+ }
+
+ /** วาดไอคอนในฉาก — ใช้ worldToScreen + zDraw จาก draw() เพื่อให้ตรงทุกมินิเกม */
+ function drawSpecialQuizIconWorld(w2s, zDraw) {
+ if (!specialQuizIcon || typeof w2s !== 'function') return;
+ const img = specialQuizGetIconImg(specialQuizIcon.iconType);
+ const sc = w2s(specialQuizIcon.x * tileSize, specialQuizIcon.y * tileSize);
+ const sx = sc[0];
+ const sy = sc[1];
+ if (!Number.isFinite(sx) || !Number.isFinite(sy)) return;
+ const size = Math.max(30, tileSize * zDraw * 1.75);
+ const bob = Math.sin(Date.now() / 320) * size * 0.06;
+ const cy = sy + bob;
+ ctx.save();
+ /* glow 08-event (light-*) หมุน+เต้นหลังไอคอน — collectible เรืองแสง */
+ const glowImg = specialQuizGetGlowImg(specialQuizIcon.iconType);
+ if (glowImg && glowImg.complete && glowImg.naturalWidth > 0) {
+ const pulse = 0.82 + Math.sin(Date.now() / 300) * 0.18;
+ const gsz = size * 2.5 * pulse;
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ ctx.globalAlpha = 0.9;
+ ctx.translate(sx, cy);
+ ctx.rotate(Date.now() / 2600);
+ ctx.drawImage(glowImg, -gsz / 2, -gsz / 2, gsz, gsz);
+ ctx.restore();
+ }
+ const r = size * 0.62;
+ const grad = ctx.createRadialGradient(sx, cy, r * 0.18, sx, cy, r);
+ grad.addColorStop(0, 'rgba(122, 162, 247, 0.5)');
+ grad.addColorStop(1, 'rgba(122, 162, 247, 0)');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ /* เก็บตำแหน่งบนจอไว้ให้กด/แตะไอคอนได้ (สำหรับมินิเกมที่เดินไปแตะไม่ได้ เช่น space shooter) */
+ specialQuizIconScreenRect = { x: sx, y: cy, r: Math.max(size * 0.7, 36) };
+ if (img && img.complete && img.naturalWidth > 0) {
+ ctx.drawImage(img, sx - size / 2, cy - size / 2, size, size);
+ } else {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.fillRect(sx - size / 2, cy - size / 2, size, size);
+ }
+ ctx.fillStyle = '#ffd166';
+ ctx.font = '700 ' + Math.round(size * 0.42) + 'px system-ui, "Kanit", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('?', sx + size * 0.4, cy - size * 0.38);
+ ctx.restore();
+ }
+
+ /** เช็คผู้เล่นเดินชนไอคอน — ใครชนก่อนเป็นคนเปิดคำถามให้ทั้งห้อง (server กันซ้ำ) */
+ function checkSpecialQuizCollision() {
+ if (!specialQuizIcon || specialQuizCollideSent || specialQuizActiveQuestion) return;
+ if (!Number.isFinite(me.x) || !Number.isFinite(me.y)) return;
+ const dx = me.x - specialQuizIcon.x;
+ const dy = me.y - specialQuizIcon.y;
+ /* รัศมีกว้างขึ้น (1.25 tile) — ไอคอนวาดใหญ่ 1.75 tile เดินเข้าใกล้ก็ทริกได้ ไม่ต้องทับ tile กลางพอดี */
+ if (dx * dx + dy * dy <= 1.25 * 1.25) {
+ sendSpecialQuizCollide();
+ }
+ }
+
+ function specialQuizOverlayEl() { return document.getElementById('special-quiz-overlay'); }
+
+ function specialQuizSelfId() { return myId != null ? String(myId) : null; }
+
+ function specialQuizMemberLabel(m) {
+ if (m && m.id && m.id === specialQuizSelfId()) return (m.nickname || 'คุณ') + ' (คุณ)';
+ return (m && m.nickname) ? m.nickname : '—';
+ }
+
+ function renderSpecialQuizMembers() {
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ box.innerHTML = '';
+ specialQuizRoster.forEach(function (m, i) {
+ const row = document.createElement('div');
+ row.className = 'sq-ov-member' + (m.id === specialQuizSelfId() ? ' sq-ov-member--me' : '');
+ row.dataset.id = m.id;
+ const dot = document.createElement('span');
+ dot.className = 'sq-ov-member-dot';
+ dot.textContent = String(i + 1);
+ const name = document.createElement('span');
+ name.className = 'sq-ov-member-name';
+ name.textContent = specialQuizMemberLabel(m);
+ const tag = document.createElement('span');
+ tag.className = 'sq-ov-member-tag';
+ tag.textContent = m.isBot ? 'บอท' : '';
+ const ans = document.createElement('span');
+ ans.className = 'sq-ov-member-ans';
+ ans.textContent = specialQuizAnswerStatus[m.id] ? '…' : '';
+ if (specialQuizAnswerStatus[m.id]) row.classList.add('sq-ov-member--answered');
+ row.appendChild(dot);
+ row.appendChild(name);
+ row.appendChild(tag);
+ row.appendChild(ans);
+ box.appendChild(row);
+ });
+ }
+
+ function markSpecialQuizMemberAnswered(id) {
+ if (!id) return;
+ specialQuizAnswerStatus[id] = true;
+ const box = document.getElementById('sq-ov-members');
+ if (!box) return;
+ const row = box.querySelector('.sq-ov-member[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
+ if (!row) { renderSpecialQuizMembers(); return; }
+ row.classList.add('sq-ov-member--answered');
+ const ans = row.querySelector('.sq-ov-member-ans');
+ if (ans && !row.classList.contains('sq-ov-member--correct') && !row.classList.contains('sq-ov-member--wrong')) ans.textContent = '✓';
+ }
+
+ function openSpecialQuizQuestion(q) {
+ if (!q) return;
+ clearSpecialQuizIcon();
+ beginSpecialQuizFreeze();
+ specialQuizActiveQuestion = {
+ index: q.index | 0,
+ total: q.total | 0,
+ choices: Array.isArray(q.choices) ? q.choices : [],
+ endsAt: Number(q.endsAt) || 0,
+ iconType: q.iconType,
+ };
+ if (Array.isArray(q.roster) && q.roster.length) specialQuizRoster = q.roster.slice();
+ specialQuizAnswerStatus = {};
+ specialQuizAnswered = false;
+ const ov = specialQuizOverlayEl();
+ if (!ov) return;
+ const iconEl = document.getElementById('sq-ov-icon');
+ if (iconEl) iconEl.src = specialQuizIconUrl(q.iconType);
+ const prog = document.getElementById('sq-ov-progress');
+ if (prog) prog.textContent = 'ข้อ ' + ((q.index | 0) + 1) + ' / ' + (q.total | 0);
+ const qEl = document.getElementById('sq-ov-question');
+ if (qEl) qEl.textContent = q.text || '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) { result.classList.add('is-hidden'); result.textContent = ''; result.className = 'sq-ov-result is-hidden'; }
+ /* แสดงเฉพาะ layer คำถาม/คำตอบ — ซ่อน layer ผลรางวัล */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.remove('is-hidden');
+ const awardLayer = document.getElementById('sq-ov-award');
+ if (awardLayer) awardLayer.classList.add('is-hidden');
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'เลือกคำตอบ — ทุกคนต้องตอบถูกทุกข้อจึงได้การ์ดพิเศษ';
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ choicesEl.innerHTML = '';
+ specialQuizActiveQuestion.choices.forEach(function (c, i) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'sq-ov-choice';
+ b.dataset.idx = String(i);
+ const k = document.createElement('span');
+ k.className = 'sq-ov-choice-key';
+ k.textContent = SPECIAL_QUIZ_CHOICE_LABELS[i] || String(i + 1);
+ const t = document.createElement('span');
+ t.className = 'sq-ov-choice-text';
+ t.textContent = c;
+ b.appendChild(k);
+ b.appendChild(t);
+ b.onclick = function () { submitSpecialQuizAnswer(i); };
+ choicesEl.appendChild(b);
+ });
+ }
+ renderSpecialQuizMembers();
+ ov.classList.remove('is-hidden');
+ startSpecialQuizTimerLoop();
+ }
+
+ function submitSpecialQuizAnswer(i) {
+ if (specialQuizAnswered || !specialQuizActiveQuestion) return;
+ specialQuizAnswered = true;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ b.classList.toggle('sq-ov-choice--picked', Number(b.dataset.idx) === i);
+ b.disabled = true;
+ });
+ }
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = 'ส่งคำตอบแล้ว — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ try { socket.emit('special-quiz-answer', { choiceIndex: i }, function () {}); } catch (e) { /* ignore */ }
+ }
+
+ function startSpecialQuizTimerLoop() {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ function tick() {
+ if (!specialQuizActiveQuestion) return;
+ const timerEl = document.getElementById('sq-ov-timer');
+ const ms = specialQuizActiveQuestion.endsAt - Date.now();
+ const s = Math.max(0, Math.ceil(ms / 1000));
+ if (timerEl) {
+ timerEl.textContent = String(s);
+ timerEl.classList.toggle('sq-ov-timer--low', s <= 5);
+ }
+ if (ms > 0) specialQuizTimerRaf = requestAnimationFrame(tick);
+ }
+ tick();
+ }
+
+ function handleSpecialQuizResult(r) {
+ if (!r || !specialQuizActiveQuestion) return;
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) {
+ Array.prototype.forEach.call(choicesEl.children, function (b) {
+ const idx = Number(b.dataset.idx);
+ b.disabled = true;
+ if (idx === r.correctIndex) b.classList.add('sq-ov-choice--correct');
+ else if (b.classList.contains('sq-ov-choice--picked')) b.classList.add('sq-ov-choice--wrong');
+ });
+ }
+ /* เฉลย — เปิดเผยว่าแต่ละคนตอบอะไร ถูก/ผิด */
+ const answers = (r.answers && typeof r.answers === 'object') ? r.answers : {};
+ const labels = SPECIAL_QUIZ_CHOICE_LABELS;
+ const box = document.getElementById('sq-ov-members');
+ if (box) {
+ Array.prototype.forEach.call(box.children, function (row) {
+ const id = row.dataset.id;
+ const a = answers[id];
+ const ansEl = row.querySelector('.sq-ov-member-ans');
+ row.classList.remove('sq-ov-member--answered');
+ if (Number.isInteger(a)) {
+ const ok = a === r.correctIndex;
+ row.classList.add(ok ? 'sq-ov-member--correct' : 'sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = (ok ? '✓ ' : '✗ ') + (labels[a] || String(a + 1));
+ } else {
+ row.classList.add('sq-ov-member--wrong');
+ if (ansEl) ansEl.textContent = '—';
+ }
+ });
+ }
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = r.allCorrect
+ ? 'ทุกคนตอบถูก!'
+ : (r.timeout ? 'หมดเวลา — ตอบไม่ครบทุกคน' : 'มีคนตอบผิด — ไม่ได้การ์ดพิเศษ');
+ result.className = 'sq-ov-result ' + (r.allCorrect ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ cancelAnimationFrame(specialQuizTimerRaf);
+ }
+
+ /** การ์ดพิเศษถูก "ใช้" — โชว์ animation + ขยายเวลามินิเกมฝั่ง client (ถ้าเป็น Time Dilation) */
+ function applySpecialCardClient(data) {
+ const card = data.card || {};
+ const addSec = Number(data.addSec) || 0;
+ const coins = Number(data.coins) || 0;
+ if (addSec > 0 && card.effectKey === 'time_dilation') {
+ extendCurrentMinigameTimeClient(addSec);
+ }
+ if (coins > 0 && card.effectKey === 'fund') {
+ /* server แจกเหรียญแล้ว (game-award.php) — แค่ดึงยอดล่าสุดมาแสดง; ถ้าไม่ใช่ (เผื่อ fallback) ค่อยบวกเอง */
+ if (data.coinsServerAwarded) resyncOwnCoins();
+ else addOwnCoinsViaApi(coins);
+ }
+ /* ถ้า overlay ควิซยังเปิด (การ์ดเพิ่งได้จากควิซ) — แผง «เล่นต่อ» โชว์การ์ดอยู่แล้ว ไม่ต้องเด้งซ้ำ */
+ const sqOv = specialQuizOverlayEl();
+ if (sqOv && !sqOv.classList.contains('is-hidden')) return;
+ showSpecialCardUseAnimation(card, { addSec: addSec, coins: coins });
+ }
+
+ /** Card 6 Fund — แต่ละ client บวกเหรียญของตัวเอง (อ่าน jdPlayerKey จาก localStorage) */
+ function addOwnCoinsViaApi(amount) {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins-add.php') : '/Admin/api/player-coins-add.php');
+ try {
+ fetch(url, {
+ method: 'POST',
+ credentials: 'omit',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playerKey: key, amount: amount }),
+ }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** ดึงยอดเหรียญล่าสุดจาก server มาอัปเดต cache (ใช้หลัง server แจกเหรียญเอง เช่น Fund) */
+ function resyncOwnCoins() {
+ let key = '';
+ try { key = (localStorage.getItem('jdPlayerKey') || '').trim(); } catch (e) { key = ''; }
+ if (!key || key.length < 8) return;
+ const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + '?playerKey=' + encodeURIComponent(key);
+ try {
+ fetch(url, { credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (d) {
+ if (d && d.ok && typeof d.coins === 'number') {
+ try { localStorage.setItem('jdCoins', String(d.coins)); } catch (e) { /* ignore */ }
+ }
+ }).catch(function () { /* ignore */ });
+ } catch (e) { /* ignore */ }
+ }
+
+ /** +วินาที ให้มินิเกมที่กำลังเล่น (jump/shooter/quiz carry) — mng8a80o จัดการฝั่ง server */
+ function extendCurrentMinigameTimeClient(sec) {
+ const ms = Math.max(0, sec * 1000);
+ if (!ms) return;
+ if (isJumpSurvive() && jumpSurviveSessionStartMs) jumpSurviveSessionStartMs += ms;
+ if (isSpaceShooter() && spaceShooterSessionStartMs) spaceShooterSessionStartMs += ms;
+ if (isQuizCarry()) {
+ if (quizCarryAnswerCloseAt > 0) quizCarryAnswerCloseAt += ms;
+ if (quizCarryOptionRevealAt > 0) quizCarryOptionRevealAt += ms;
+ }
+ }
+
+ function showSpecialCardUseAnimation(card, opts) {
+ opts = opts || {};
+ let ov = document.getElementById('special-card-use-overlay');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'special-card-use-overlay';
+ ov.className = 'is-hidden';
+ ov.innerHTML =
+ '' +
+ '
';
+ document.body.appendChild(ov);
+ }
+ const img = ov.querySelector('.scu-img');
+ const nameEl = ov.querySelector('.scu-name');
+ const fxEl = ov.querySelector('.scu-effect');
+ if (img) img.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ if (nameEl) nameEl.textContent = 'ใช้การ์ด: ' + (card.en || card.th || 'Special Card');
+ if (fxEl) {
+ let fx = card.desc || card.th || '';
+ if (opts.addSec > 0) fx = '+' + opts.addSec + ' วินาที';
+ else if (opts.coins > 0) fx = 'ทุกคน +' + opts.coins + ' COINS';
+ fxEl.textContent = fx;
+ }
+ ov.classList.remove('is-hidden');
+ ov.classList.remove('scu-play');
+ void ov.offsetWidth;
+ ov.classList.add('scu-play');
+ clearTimeout(showSpecialCardUseAnimation._t);
+ showSpecialCardUseAnimation._t = setTimeout(function () {
+ ov.classList.add('is-hidden');
+ ov.classList.remove('scu-play');
+ }, 2600);
+ }
+
+ /** ปิด overlay + คืนเวลาเล่นต่อ (เรียกตอนกด «เล่นต่อ» หรือ safety timeout) */
+ function specialQuizContinueAndResume() {
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = 0;
+ const ov = specialQuizOverlayEl();
+ const award = document.getElementById('sq-ov-award');
+ if (award) award.classList.add('is-hidden');
+ if (ov) ov.classList.add('is-hidden');
+ /* แจ้ง server ให้เล่นรอบ quiz (mng8a80o) ต่อ — มินิเกมอื่น freeze ฝั่ง client พอ */
+ try { socket.emit('special-quiz-continue', {}, function () {}); } catch (e) { /* ignore */ }
+ endSpecialQuizFreeze();
+ }
+
+ function endSpecialQuiz(data) {
+ cancelAnimationFrame(specialQuizTimerRaf);
+ specialQuizActiveQuestion = null;
+ specialQuizAnswered = false;
+ const success = !!(data && data.success && data.card);
+ const card = success ? data.card : null;
+ if (success) specialQuizAwardedCard = card;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '';
+ const result = document.getElementById('sq-ov-result');
+ if (result) {
+ result.classList.remove('is-hidden');
+ result.textContent = success ? 'ได้รับการ์ดพิเศษ!' : 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ result.className = 'sq-ov-result ' + (success ? 'sq-ov-result--ok' : 'sq-ov-result--fail');
+ }
+ /* แผงโชว์การ์ด + ปุ่ม «เล่นต่อ» — เกมหยุดค้างจนกว่าจะกด */
+ const award = document.getElementById('sq-ov-award');
+ const titleEl = document.getElementById('sq-ov-award-title');
+ const imgEl = document.getElementById('sq-ov-award-img');
+ const nameEl = document.getElementById('sq-ov-award-name');
+ const fxEl = document.getElementById('sq-ov-award-effect');
+ const btn = document.getElementById('sq-ov-award-continue');
+ if (award) {
+ if (success) {
+ if (titleEl) titleEl.textContent = 'ได้รับการ์ดพิเศษ!';
+ if (imgEl) {
+ imgEl.removeAttribute('hidden');
+ imgEl.src = card.imageUrl || (card.cardId ? '/Game/img/special-cards/card-' + card.cardId + '.png' : '');
+ }
+ if (nameEl) nameEl.textContent = card.en || card.th || ('การ์ด #' + (card.cardId || ''));
+ if (fxEl) fxEl.textContent = card.desc || card.th || '';
+ } else {
+ if (titleEl) titleEl.textContent = 'รอบนี้ไม่ได้การ์ดพิเศษ';
+ if (imgEl) { imgEl.setAttribute('hidden', ''); imgEl.removeAttribute('src'); }
+ if (nameEl) nameEl.textContent = '';
+ if (fxEl) fxEl.textContent = 'ตอบถูกครบทุกคนเพื่อรับการ์ดพิเศษในครั้งหน้า';
+ }
+ if (btn) btn.onclick = specialQuizContinueAndResume;
+ /* สลับ layer: ซ่อนคำถาม/คำตอบ แล้วโชว์ผลรางวัลเต็มหน้า */
+ const quizBody = document.getElementById('sq-ov-quiz-body');
+ if (quizBody) quizBody.classList.add('is-hidden');
+ award.classList.remove('is-hidden');
+ } else {
+ /* ไม่มี element สำรอง — เล่นต่อทันที */
+ specialQuizContinueAndResume();
+ return;
+ }
+ /* กันค้าง: ถ้าไม่กดภายใน 30 วิ ให้ไปต่อเอง */
+ clearTimeout(endSpecialQuiz._safety);
+ endSpecialQuiz._safety = setTimeout(specialQuizContinueAndResume, 30000);
+ }
+
+ function showGauntletCrownMissionOverlay(mission) {
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ const rowEl = document.getElementById('gcm-rank-row');
+ const totalEl = document.getElementById('gcm-total');
+ const gradeEl = document.getElementById('gcm-grade');
+ const bonusEl = document.getElementById('gcm-bonus');
+ const btn = document.getElementById('btn-gcm-done');
+ if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return;
+ if (!mission.uiSkin && isGauntletCrownHeistMapPlay()) mission.uiSkin = 'gauntlet_crown';
+ const missionSkin = resolveGauntletCrownMissionUiSkinPlay(mission);
+ if (!mission.specialCardAwarded && specialQuizAwardedCard) mission.specialCardAwarded = specialQuizAwardedCard;
+ let disp = gauntletCrownBuildDisplayMission(mission);
+ if (mission.uiSkin === 'jumper') {
+ disp = jumpSurviveMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'violent_crime') {
+ disp = spaceShooterMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'question_mission') {
+ disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'stack_tower') {
+ disp = stackTowerMissionMergePreviewBotsMission(disp) || disp;
+ } else if (mission.uiSkin === 'mega_virus') {
+ disp = balloonBossMergePreviewBotsMission(disp) || disp;
+ }
+ if (!disp || !Array.isArray(disp.ranked)) return;
+ /* mission map — สรุปผลภารกิจตาม mockup (Minigame 1/4–7 + Last Light 07-summary) */
+ const tfMockSummary = isGcmMissionMockSummaryPlay(mission);
+ const mg2Summary = tfMockSummary && missionSkin === 'gauntlet_crown';
+ ov.classList.toggle('gcm-quiz-tf-mock', tfMockSummary);
+ ov.classList.toggle('gcm-mg2-mock', mg2Summary);
+ const gcmVrule = document.getElementById('gcm-vrule');
+ if (gcmVrule) {
+ gcmVrule.classList.toggle('is-hidden', !mg2Summary);
+ gcmVrule.setAttribute('aria-hidden', mg2Summary ? 'true' : 'true');
+ }
+ const gcmBgImg = ov.querySelector('.gcm-bg');
+ if (gcmBgImg) {
+ const gcmBgSrc = tfMockSummary
+ ? gcmMissionMockAssetUrl(mission, 'popup-result.png')
+ : (BASE + '/img/gauntlet-assets/popup-result.png');
+ if (gcmBgImg.getAttribute('src') !== gcmBgSrc) gcmBgImg.setAttribute('src', gcmBgSrc);
+ }
+ if (tfMockSummary) {
+ ensureMePlayTintFromLobbyStorage();
+ ensureQuizTfScaleResizeListener();
+ updateGcmTfMockScale(ov);
+ /* preload tint ทุกคนก่อนวาด avatar — กันรูปว่าง/สีเดียว */
+ disp.ranked.forEach(function (r) {
+ const cid = resolveRankedCharacterIdPlay(r);
+ if (!cid) return;
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }));
+ });
+ } else {
+ ov.style.removeProperty('--gcm-tf-scale');
+ }
+ rowEl.innerHTML = '';
+ disp.ranked.forEach((r) => {
+ const cell = document.createElement('div');
+ cell.className = 'gcm-cell';
+ const tag = document.createElement('div');
+ tag.className = 'gcm-rank-tag';
+ const rk = Math.floor(Number(r.rank)) || 0;
+ if (tfMockSummary) {
+ /* mockup: ทุกอันดับเป็นภาพ (result-1st/2nd/3rd, txt-1=4, txt-2=5+) */
+ const badge = rk === 1 ? 'result-1st.png' : rk === 2 ? 'result-2nd.png'
+ : rk === 3 ? 'result-3rd.png' : rk === 4 ? 'result-txt-1.png' : 'result-txt-2.png';
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmMissionMockAssetUrl(mission, badge);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else if (rk >= 1 && rk <= 3) {
+ const tagImg = document.createElement('img');
+ tagImg.src = gcmRankBadgeUrl(rk);
+ tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
+ tagImg.style.maxWidth = '100%';
+ tagImg.style.height = 'auto';
+ tag.appendChild(tagImg);
+ } else {
+ tag.textContent = '[' + String(r.rankLabel || rk) + ']';
+ }
+ const frame = document.createElement('div');
+ frame.className = 'gcm-frame';
+ const img = document.createElement('img');
+ img.className = 'gcm-av';
+ img.alt = '';
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ if (cid) {
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ } else {
+ img.src = defaultAvatarImg.src;
+ }
+ img.onerror = function () {
+ this.onerror = null;
+ applyCyberHudScoreAvatarImgWithRetry(this, avRow, 3);
+ };
+ if (tfMockSummary) {
+ /* mission mock: หน้าใน gcm-av-clip z3 ทับ player-frame z2 (กลางกรอบทึบ) */
+ const avClip = document.createElement('div');
+ avClip.className = 'gcm-av-clip';
+ avClip.appendChild(img);
+ frame.appendChild(avClip);
+ const frameImg = document.createElement('img');
+ frameImg.className = 'gcm-frame-img';
+ frameImg.src = gcmMissionMockAssetUrl(mission, 'player-frame.png');
+ frameImg.alt = '';
+ frameImg.decoding = 'async';
+ frame.appendChild(frameImg);
+ } else {
+ frame.appendChild(img);
+ }
+ const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong;
+ if (tfMockSummary && (r.eliminated || qMissionLose)) cell.classList.add('gcm-cell--out');
+ if (r.eliminated || qMissionLose) {
+ const st = document.createElement('img');
+ st.className = 'gcm-stamp';
+ const jSkin = mission && mission.uiSkin === 'jumper';
+ let loseUrl = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ let altLose = 'เสียชีวิต';
+ if (jSkin && r.eliminated) {
+ loseUrl = jumperAssetUrl('stamp-sacrifice.png');
+ altLose = 'ผู้เสียสละ';
+ } else if (qMissionLose) {
+ loseUrl = BASE + '/img/QUESTION/result-Lose_stamp.png';
+ altLose = 'ตอบผิด';
+ }
+ st.src = loseUrl;
+ st.alt = altLose;
+ st.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
+ this.alt = 'ตอบผิด';
+ };
+ frame.appendChild(st);
+ }
+ const nick = document.createElement('div');
+ nick.className = 'gcm-nick';
+ nick.textContent = r.nickname || '—';
+ const sc = document.createElement('div');
+ sc.className = 'gcm-sc';
+ const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus');
+ const botScorePlain = (playBotsEnabled() && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id) && !mg2Summary)
+ || (playBotsEnabled() && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id))
+ || (playBotsEnabled() && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id));
+ if (mg2Summary) {
+ const bs = Math.max(0, Number(r.baseScore) || 0);
+ const rb = Math.max(0, Number(r.rankBonus) || 0);
+ sc.textContent = '';
+ sc.appendChild(document.createTextNode(String(bs)));
+ if (rb > 0) {
+ const bon = document.createElement('span');
+ bon.className = 'gcm-sc-bonus';
+ bon.textContent = '(+' + String(rb) + ')';
+ sc.appendChild(bon);
+ }
+ } else {
+ sc.textContent = (jumpPlain || botScorePlain)
+ ? String(Math.max(0, Number(r.baseScore) || 0))
+ : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')';
+ }
+ cell.appendChild(tag);
+ cell.appendChild(frame);
+ cell.appendChild(nick);
+ cell.appendChild(sc);
+ rowEl.appendChild(cell);
+ });
+ if (totalEl) {
+ const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0)));
+ const sum = Math.max(0, Number(disp.totalSum) || 0);
+ const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0));
+ const jumperSkin = mission && mission.uiSkin === 'jumper';
+ const violentSkin = mission && mission.uiSkin === 'violent_crime';
+ const questionMissionSkin = mission && mission.uiSkin === 'question_mission';
+ if (tfMockSummary) {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') =' + sum + ' ';
+ } else if (jumperSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด';
+ } else if (violentSkin) {
+ const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · รอดชีวิต ' + sc + '/' + pc + ' → เกรด';
+ } else if (questionMissionSkin) {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนตอบคำถาม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'stack_tower') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else if (mission && mission.uiSkin === 'mega_virus') {
+ const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
+ totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
+ } else {
+ totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด';
+ }
+ }
+ if (gradeEl) {
+ const g = String(disp.grade || 'C').toUpperCase().charAt(0);
+ gradeEl.textContent = g;
+ gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase();
+ }
+ if (bonusEl) {
+ bonusEl.innerHTML = '';
+ const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0);
+ const missionOk = gLetter !== 'F';
+ let bonusCardHost = bonusEl;
+ if (mg2Summary && missionOk) {
+ const bonusWrap = document.createElement('div');
+ bonusWrap.className = 'gcm-bonus-mg2 sm-bonus';
+ const bonusLeft = document.createElement('div');
+ bonusLeft.className = 'gcm-bonus-mg2-left sm-bonus-left';
+ const bonusHead = document.createElement('h3');
+ bonusHead.className = 'gcm-h gcm-bonus-mg2-head head';
+ bonusHead.textContent = 'โบนัสพิเศษ';
+ const completeImg = document.createElement('img');
+ completeImg.className = 'gcm-bonus-mg2-complete complete';
+ completeImg.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ completeImg.alt = 'ภารกิจสำเร็จ';
+ completeImg.decoding = 'async';
+ completeImg.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ bonusLeft.appendChild(bonusHead);
+ bonusLeft.appendChild(completeImg);
+ const bonusRight = document.createElement('div');
+ bonusRight.className = 'gcm-bonus-mg2-right sm-bonus-right';
+ bonusWrap.appendChild(bonusLeft);
+ bonusWrap.appendChild(bonusRight);
+ bonusEl.appendChild(bonusWrap);
+ bonusCardHost = bonusRight;
+ } else if (missionOk) {
+ const row = document.createElement('div');
+ row.className = 'gcm-bonus-okrow';
+ row.style.display = 'flex';
+ row.style.alignItems = 'center';
+ row.style.gap = '8px';
+ const chk = document.createElement('img');
+ const stackTS = mission && mission.uiSkin === 'stack_tower';
+ const megaVs = mission && mission.uiSkin === 'mega_virus';
+ chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png'));
+ chk.alt = '';
+ if (stackTS || megaVs) {
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ }
+ if (tfMockSummary) {
+ chk.src = gcmMissionMockAssetUrl(mission, 'mission-complete-select.png');
+ chk.alt = 'ภารกิจสำเร็จ';
+ chk.onerror = function () {
+ this.onerror = null;
+ this.src = BASE + '/img/gauntlet-assets/result-check.png';
+ };
+ row.appendChild(chk);
+ } else {
+ chk.width = 26;
+ chk.height = 26;
+ const sp = document.createElement('span');
+ sp.style.color = '#9ece6a';
+ sp.style.fontWeight = '700';
+ sp.textContent = 'ภารกิจสำเร็จ';
+ row.appendChild(chk);
+ row.appendChild(sp);
+ }
+ bonusEl.appendChild(row);
+ }
+ const rc = disp.rewardCard;
+ if (!mg2Summary && missionOk && rc && rc.th) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.innerHTML = '' + String(rc.th) + ' ' + String(rc.en || '') + ' ';
+ bonusEl.appendChild(pl);
+ } else if (!mg2Summary && !missionOk) {
+ const pl = document.createElement('div');
+ pl.className = 'gcm-plaque';
+ pl.style.borderColor = 'rgba(148, 153, 168, 0.45)';
+ pl.style.opacity = '0.95';
+ pl.innerHTML = 'ไม่ได้รับการ์ด เกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์ ';
+ bonusEl.appendChild(pl);
+ }
+ if (mission && mission.specialCardAwarded) {
+ renderSpecialCardInBonus(bonusCardHost, mission.specialCardAwarded);
+ } else if (mg2Summary && missionOk && rc && rc.th) {
+ const cardId = rc.kind === 'culprit' ? 1 : (rc.kind === 'rare' ? 5 : 3);
+ renderSpecialCardInBonus(bonusCardHost, {
+ th: rc.th,
+ en: rc.en || '',
+ imageUrl: '/Game/img/special-cards/card-' + cardId + '.png',
+ cardId: cardId,
+ rarity: rc.kind === 'rare' ? 'rare' : (rc.kind === 'culprit' ? 'legendary' : 'common'),
+ });
+ }
+ }
+ const gcmHead = document.getElementById('gcm-heading');
+ if (gcmHead) {
+ if (mission && (mission.uiSkin === 'gauntlet_crown' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) gcmHead.classList.add('sr-only');
+ else gcmHead.classList.remove('sr-only');
+ }
+ if (btn && mg2Summary) {
+ btn.style.backgroundImage = "url('" + gcmMissionMockAssetUrl(mission, 'btn-done.png') + "')";
+ } else if (btn) {
+ btn.style.removeProperty('background-image');
+ }
+ ov.classList.remove('is-hidden');
+ if (tfMockSummary) {
+ requestAnimationFrame(function () {
+ refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked);
+ });
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 200);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 550);
+ setTimeout(function () { refreshGcmTfMockAvatarsPlay(rowEl, disp.ranked); }, 1200);
+ }
+ function goLobby() {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (tryFinishDetectiveMinigameAndReturnLobby()) return;
+ window.location.href = buildRoomLobbyReturnHref();
+ }
+ if (btn) {
+ btn.onclick = function () {
+ if (previewMode && editorEmbedReturn) {
+ ov.classList.add('is-hidden');
+ if (gcmHead) gcmHead.classList.remove('sr-only');
+ if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'quiz_carry')) {
+ cancelQuizCarryResultEndAfterTimeup();
+ hideQuizCarryTimeupOnDeskLayer();
+ hideQuizCarryResultEndLayer();
+ scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
+ } else {
+ showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); });
+ }
+ } else {
+ goLobby();
+ }
+ };
+ }
+ }
+
+ function isLobby() { return mapData && mapData.gameType === 'lobby'; }
+ function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
+ function getLane(y) {
+ if (!mapData || !mapData.lanes) return null;
+ const lane = mapData.lanes.find(l => l.y === y);
+ return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
+ }
+ function getVehiclePositions(lane, width, timeMs) {
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
+ const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
+ const dir = lane.dir === -1 ? -1 : 1;
+ const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
+ const period = width + 2;
+ const count = Math.max(2, Math.floor(period / spacing));
+ const positions = [];
+ for (let i = 0; i < count; i++) {
+ const p = i * spacing + (timeMs * speed * dir) / 60;
+ const pNorm = ((p % period) + period) % period;
+ const vx = pNorm - 1;
+ positions.push(Math.max(-1, Math.min(width, vx)));
+ }
+ return positions;
+ }
+ function checkFroggerCollision() {
+ const fy = Math.floor(me.y);
+ const lane = getLane(fy);
+ if (!lane) return false;
+ if (lane.type === 'road') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.1) return true;
+ }
+ }
+ if (lane.type === 'water') {
+ const positions = getVehiclePositions(lane, mapData.width, Date.now());
+ let onLog = false;
+ for (let i = 0; i < positions.length; i++) {
+ if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
+ }
+ if (!onLog) return true;
+ }
+ return false;
+ }
+ function respawnFrogger() {
+ const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
+ me.x = sp.x; me.y = sp.y;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+
+ /** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
+ function peerXYFromJoin(peer, spawnFb) {
+ const sx = Number(spawnFb && spawnFb.x);
+ const sy = Number(spawnFb && spawnFb.y);
+ const defX = Number.isFinite(sx) ? sx : 1;
+ const defY = Number.isFinite(sy) ? sy : 1;
+ if (!peer) return { x: defX, y: defY };
+ const x = Number(peer.x);
+ const y = Number(peer.y);
+ if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+ return { x: defX, y: defY };
+ }
+
+ /** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
+ function normalizeQuizCarryLayersInPlay(md) {
+ if (!md || md.gameType !== 'quiz_carry') return;
+ const w = md.width || 20, h = md.height || 15;
+ const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
+ const srcHub = md.quizCarryHubArea || [];
+ const hubRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcHub[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ hubRows.push(row);
+ }
+ md.quizCarryHubArea = hubRows;
+ const srcOpt = md.quizCarryOptionArea || [];
+ const optRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcOpt[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
+ }
+ optRows.push(row);
+ }
+ md.quizCarryOptionArea = optRows;
+ const srcCd = md.carryEmbedCountdownArea || [];
+ const cdRows = [];
+ for (let y = 0; y < h; y++) {
+ const r = srcCd[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ row.push(Number(v) === 1 ? 1 : 0);
+ }
+ cdRows.push(row);
+ }
+ md.carryEmbedCountdownArea = cdRows;
+ }
+
+ /** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
+ function normalizeQuizBattleDomeInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattleDomeArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattleDomeArea = grid;
+ /* 1) flood-fill หากลุ่มโดม (raw component = 1 สถานีคำถาม) */
+ const raw = Array(h).fill(0).map(() => Array(w).fill(0));
+ const compsCells = []; // compsCells[i] = [[x,y],...] ของกลุ่มที่ i (rawId = i+1)
+ let rawId = 0;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (grid[y][x] !== 1 || raw[y][x]) continue;
+ rawId++;
+ const cells = [];
+ const stack = [[x, y]];
+ raw[y][x] = rawId;
+ while (stack.length) {
+ const c = stack.pop();
+ const cx = c[0], cy = c[1];
+ cells.push([cx, cy]);
+ for (let i = 0; i < 4; i++) {
+ const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
+ const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
+ const nx = cx + dx, ny = cy + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (grid[ny][nx] !== 1 || raw[ny][nx]) continue;
+ raw[ny][nx] = rawId;
+ stack.push([nx, ny]);
+ }
+ }
+ compsCells.push(cells);
+ }
+ }
+ /* 2) BFS ระยะทางจากจุดเกิด (เดินบนเส้นทาง+โดม) เพื่อจัดเลขโดมตามลำดับ "เดินถึงก่อน = เลขน้อยกว่า" */
+ const path = md.quizBattlePathArea;
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const bq = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ const walkable = (tx, ty) => {
+ const onPath = path && path[ty] && path[ty][tx] === 1;
+ const onDome = grid[ty] && grid[ty][tx] === 1;
+ return onPath || onDome;
+ };
+ const enq = (tx, ty, d) => {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0 || !walkable(tx, ty)) return;
+ dist[ty][tx] = d; bq.push([tx, ty, d]);
+ };
+ enq(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) enq(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) if (row[tx] === 1) enq(tx, ty, 0); }
+ }
+ let head = 0;
+ while (head < bq.length) {
+ const cur = bq[head++];
+ const cx = cur[0], cy = cur[1], cd = cur[2];
+ enq(cx + 1, cy, cd + 1); enq(cx - 1, cy, cd + 1); enq(cx, cy + 1, cd + 1); enq(cx, cy - 1, cd + 1);
+ }
+ /* 3) ระยะทางต่ำสุดของแต่ละกลุ่ม → เรียง → ให้เลข 1..N (กลุ่มที่ไปไม่ถึงไว้ท้าย) */
+ const order = compsCells.map((cells, i) => {
+ let md0 = Infinity;
+ for (const [cx, cy] of cells) { const dd = dist[cy][cx]; if (dd >= 0 && dd < md0) md0 = dd; }
+ return { idx: i, minDist: md0, firstCell: cells[0] };
+ });
+ order.sort((a, b) => (a.minDist - b.minDist) || (a.firstCell[1] - b.firstCell[1]) || (a.firstCell[0] - b.firstCell[0]));
+ const comp = Array(h).fill(0).map(() => Array(w).fill(0));
+ order.forEach((o, rank) => { for (const [cx, cy] of compsCells[o.idx]) comp[cy][cx] = rank + 1; });
+ md.quizBattleDomeComp = comp;
+ }
+
+ function hasQuizBattleManualPathGatesPlay(md) {
+ const mg = md && md.quizBattlePathGateArea;
+ if (!mg || !Array.isArray(mg)) return false;
+ for (let ty = 0; ty < mg.length; ty++) {
+ const row = mg[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < row.length; tx++) {
+ if ((row[tx] | 0) > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ function buildQuizBattlePathGateFromManualPlay(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const manual = md.quizBattlePathGateArea;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+
+ function enqueue(tx, ty, req) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return;
+ bestReq[ty][tx] = req;
+ queue.push({ tx, ty, req });
+ }
+
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+ enqueue(stx, sty, 0);
+ if (bestReq[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) enqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, req } = cur;
+ if (bestReq[ty][tx] !== req) continue;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ let enterReq = req;
+ const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0;
+ if (onDome) enterReq = 0;
+ else if (mg > 0) enterReq = Math.max(enterReq, mg);
+ if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq;
+ let outReq = enterReq;
+ if (mg > 0) outReq = Math.max(outReq, mg);
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ enqueue(tx + dx, ty + dy, outReq);
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ /** ประตูบนเลน — เลนหลังโดม C ต้องตอบถูกโดม 1..C ก่อน (ยืนบนโดมได้โดยไม่ต้องตอบโดมนั้น) */
+ function buildQuizBattlePathGateRequiredPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActive(md)) {
+ if (md) md.quizBattlePathGateRequired = null;
+ return;
+ }
+ if (hasQuizBattleManualPathGatesPlay(md)) {
+ buildQuizBattlePathGateFromManualPlay(md);
+ return;
+ }
+ normalizeQuizBattleDomeInPlay(md);
+ const w = md.width || 20, h = md.height || 15;
+ const path = md.quizBattlePathArea;
+ const dome = md.quizBattleDomeArea;
+ const domeComp = md.quizBattleDomeComp;
+ const gate = Array(h).fill(0).map(() => Array(w).fill(0));
+ const dist = Array(h).fill(0).map(() => Array(w).fill(-1));
+ const queue = [];
+ const sp = md.spawn || { x: 1, y: 1 };
+ const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0));
+ const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0));
+
+ function tryEnqueue(tx, ty, d) {
+ if (tx < 0 || ty < 0 || tx >= w || ty >= h) return;
+ if (dist[ty][tx] >= 0) return;
+ const onPath = path[ty] && path[ty][tx] === 1;
+ const onDome = dome && dome[ty] && dome[ty][tx] === 1;
+ if (!onPath && !onDome) return;
+ dist[ty][tx] = d;
+ queue.push({ tx, ty, d });
+ }
+
+ tryEnqueue(stx, sty, 0);
+ if (dist[sty][stx] < 0) {
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0);
+ }
+ const sa = md.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] === 1) tryEnqueue(tx, ty, 0);
+ }
+ }
+ }
+
+ const domeMinDist = {};
+ let maxComp = 0;
+ while (queue.length) {
+ const cur = queue.shift();
+ const { tx, ty, d } = cur;
+ const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0;
+ if (dc > 0) {
+ maxComp = Math.max(maxComp, dc);
+ if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d;
+ }
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (dist[ny][nx] >= 0) continue;
+ const onPath = path[ny] && path[ny][nx] === 1;
+ const onDome = dome && dome[ny] && dome[ny][nx] === 1;
+ if (onPath || onDome) tryEnqueue(nx, ny, d + 1);
+ }
+ }
+
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ const d = dist[ty][tx];
+ if (d < 0) {
+ gate[ty][tx] = maxComp;
+ continue;
+ }
+ let req = 0;
+ for (let c = 1; c <= maxComp; c++) {
+ if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c);
+ }
+ gate[ty][tx] = req;
+ }
+ }
+ md.quizBattlePathGateRequired = gate;
+ }
+
+ function quizBattlePathGateRequiredAtPlay(md, px, py) {
+ const gate = md && md.quizBattlePathGateRequired;
+ if (!gate || !quizBattlePathModeActive(md)) return 0;
+ const path = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ let maxReq = 0;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!path[ty] || path[ty][tx] !== 1) continue;
+ maxReq = Math.max(maxReq, gate[ty][tx] || 0);
+ }
+ return maxReq;
+ }
+
+ function quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) {
+ const maxReq = quizBattlePathGateRequiredAtPlay(md, px, py);
+ if (maxReq <= 0) return 0;
+ for (let c = 1; c <= maxReq; c++) {
+ if (!solvedSet.has(c)) return c;
+ }
+ return 0;
+ }
+
+ function quizBattlePathGateAllowsPlay(md, px, py, solvedSet) {
+ if (quizBattleFootprintOnDomePlay(md, px, py)) return true;
+ return quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet) === 0;
+ }
+
+ /**
+ * คำถามของโดม compId → index ใน pool — เคารพ "คำถามที่กำหนดเองต่อโดม" (quizBattleDomeQuestionArea) จาก Editor
+ * ถ้าไม่กำหนด (0) ใช้ตามลำดับเดิน (compId-1)%len เหมือนเดิม
+ */
+ function quizBattleQuestionIndexForCompPlay(compId, poolLen) {
+ if (!(poolLen > 0)) return 0;
+ let override = 0;
+ const md = mapData;
+ if (md && Array.isArray(md.quizBattleDomeComp) && Array.isArray(md.quizBattleDomeQuestionArea)) {
+ const comp = md.quizBattleDomeComp, qa = md.quizBattleDomeQuestionArea;
+ for (let y = 0; y < comp.length && !override; y++) {
+ const cr = comp[y], qr = qa[y];
+ if (!cr || !qr) continue;
+ for (let x = 0; x < cr.length; x++) {
+ if (cr[x] === compId && (qr[x] | 0) > 0) { override = qr[x] | 0; break; }
+ }
+ }
+ }
+ const idx = (override > 0 ? override - 1 : compId - 1);
+ return ((idx % poolLen) + poolLen) % poolLen;
+ }
+
+ let quizBattleGateFlashAt = 0;
+ function quizBattleGateQuestionSnippetPlay(compId) {
+ if (!(compId > 0) || !quizBattleMcqPool.length) return '';
+ const q = quizBattleMcqPool[quizBattleQuestionIndexForCompPlay(compId, quizBattleMcqPool.length)];
+ if (!q || !q.text) return '';
+ const t = String(q.text).trim();
+ return t.length > 42 ? t.slice(0, 42) + '…' : t;
+ }
+
+ function quizBattleFlashGateBlocked(md, px, py, solvedSet) {
+ const need = quizBattleFirstMissingGateCompPlay(md, px, py, solvedSet);
+ if (!need) return;
+ const now = performance.now();
+ if (now - quizBattleGateFlashAt < 1400) return;
+ quizBattleGateFlashAt = now;
+ const snip = quizBattleGateQuestionSnippetPlay(need);
+ flashQuizBattleFeedback(
+ snip
+ ? ('ตอบข้อ ' + need + ' ก่อนผ่าน: 「' + snip + '」')
+ : ('ตอบคำถามข้อ ' + need + ' ก่อนผ่านทางนี้'),
+ false
+ );
+ }
+
+ /** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */
+ function normalizeQuizBattlePathInPlay(md) {
+ if (!md || md.gameType !== 'quiz_battle') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.quizBattlePathArea || [];
+ const grid = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ grid.push(row);
+ }
+ md.quizBattlePathArea = grid;
+ }
+
+ function quizBattlePathModeActive(md) {
+ if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false;
+ const g = md.quizBattlePathArea;
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y];
+ if (!row) continue;
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
+ }
+ return false;
+ }
+
+ /** ทุกช่องที่สไปรต์กินพื้นที่ต้องอยู่บนเลน — กันหลุดไปพื้นเปิด (path=0) หรือทับกำแพง (objects=1) */
+ function quizBattleFootprintFullyOnPath(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ const g = md.quizBattlePathArea;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!g[ty] || g[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) — โซนรอก่อนลงเลน */
+ /** footprint แตะโซนจุดจบ (quizBattleFinishArea) ไหม — ใช้อนุญาตให้ยืน/เด้งไปจุดจบได้โดยไม่ถูกสแน็ปกลับเลน */
+ function quizBattleFootprintTouchesFinishPlay(md, px, py) {
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (fa[ty] && fa[ty][tx] === 1) return true;
+ }
+ const cx = Math.floor(px), cy = Math.floor(py);
+ return !!(fa[cy] && fa[cy][cx] === 1);
+ }
+
+ function quizBattlePositionAllowedPlay(md, px, py) {
+ if (!md || !quizBattlePathModeActive(md)) return true;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
+ if (quizBattleFootprintFullyOnPath(md, px, py)) return true;
+ if (quizBattleFootprintTouchesFinishPlay(md, px, py)) return true; /* โซนจุดจบ — ยืนได้ */
+ const sa = md.spawnArea;
+ if (!sa || !Array.isArray(sa)) return false;
+ const tiles = quizBattleSpriteBoundsTilesPlay(md, px, py);
+ if (tiles.size === 0) return false;
+ for (const k of tiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!sa[ty] || sa[ty][tx] !== 1) return false;
+ }
+ return true;
+ }
+
+ /** สปอว์นในพื้นที่ฟ้า — เช็คแค่ไม่ทับกำแพง ไม่บังคับอยู่บนเลน */
+ function quizBattleSpawnWallClearPlay(md, nx, ny) {
+ if (!md || !md.objects) return false;
+ if (typeof nx !== 'number' || typeof ny !== 'number' || !Number.isFinite(nx) || !Number.isFinite(ny)) return false;
+ const w = md.width || 20, h = md.height || 15;
+ const prevMap = mapData;
+ mapData = md;
+ try {
+ for (const k of quizTilesWallCollisionFootprintPlay(nx, ny)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = md.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ return true;
+ } finally {
+ mapData = prevMap;
+ }
+ }
+
+ /** เดินทีละย่อย — เลนบาง 1 ช่อง + กล่องสไปรต์ใหญ่กว่าเท้า ยังเดินได้โดยไม่หลุดเลน */
+ function quizBattleTryStepMovePlay(fx, fy, tx, ty, canWalkFn) {
+ const SUB = 8;
+ let x = fx;
+ let y = fy;
+ for (let i = 1; i <= SUB; i++) {
+ const t = i / SUB;
+ const nx = fx + (tx - fx) * t;
+ const ny = fy + (ty - fy) * t;
+ if (canWalkFn(nx, ny, x, y)) {
+ x = nx;
+ y = ny;
+ }
+ }
+ return { x, y };
+ }
+
+ /** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */
+ function quizBattleSnapTargetValidPlay(x, y) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ for (const k of quizTilesWallCollisionFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (quizBattlePathModeActive(mapData) && !quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ return true;
+ }
+
+ function snapPositionOntoQuizBattlePathIfNeeded(px, py) {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py };
+ if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const g = mapData.quizBattlePathArea;
+ let bestX = null, bestY = null, bestD = Infinity;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.01;
+ const ny = ty + 0.01;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ /* footprint ใหญ่กว่าเลน: ลองจุดกลางช่อง — ต้องผ่านกล่องสไปรต์+path เหมือนก้อนบน (เดิมเช็คแค่ไม่ทับกำแพง → snap หลุดเลน) */
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = 0; tx < w; tx++) {
+ if (!g[ty] || g[ty][tx] !== 1) continue;
+ const nx = tx + 0.5;
+ const ny = ty + 0.5;
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ const sa = mapData.spawnArea;
+ if (sa && Array.isArray(sa)) {
+ for (let ty = 0; ty < h; ty++) {
+ const row = sa[ty];
+ if (!row) continue;
+ for (let tx = 0; tx < w; tx++) {
+ if (row[tx] !== 1) continue;
+ for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) {
+ if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
+ const d = Math.abs(nx - px) + Math.abs(ny - py);
+ if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
+ }
+ }
+ }
+ }
+ if (bestX != null) return { x: bestX, y: bestY };
+ return { x: px, y: py };
+ }
+
+ /** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */
+ function enforceQuizBattleLaneOnMePlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ if (quizBattleSnapTargetValidPlay(me.x, me.y)) return;
+ const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = snapped.x;
+ me.y = snapped.y;
+ }
+
+ /** สแน็ป peer / บอทหลัง lerp — กันตัวละครล้ำกำแพงหรือหลุดเลนระหว่างเฟรม */
+ function enforceQuizBattleLaneOnPeersPlay() {
+ if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
+ others.forEach((o) => {
+ if (!o) return;
+ if (quizBattleSnapTargetValidPlay(o.x, o.y)) return;
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = sn.x;
+ o.y = sn.y;
+ o.tx = sn.x;
+ o.ty = sn.y;
+ });
+ }
+
+ function hideQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.classList.add('is-hidden');
+ ov.setAttribute('aria-hidden', 'true');
+ }
+ quizBattleChoiceLocked = false;
+ }
+
+ function flashQuizBattleFeedback(text, ok) {
+ const el = document.getElementById('play-quiz-feedback');
+ if (!el) return;
+ el.textContent = text || '';
+ el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad');
+ el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad');
+ if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT);
+ window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800);
+ }
+
+ function getQuizBattleDomeCompIdsUnderFootprint(px, py) {
+ if (!mapData || !isQuizBattle()) return [];
+ const comp = mapData.quizBattleDomeComp;
+ const grid = mapData.quizBattleDomeArea;
+ if (!comp || !grid) return [];
+ const ids = [];
+ const seen = Object.create(null);
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ const cid = comp[ty] && comp[ty][tx];
+ if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) {
+ seen[cid] = 1;
+ ids.push(cid);
+ }
+ }
+ return ids;
+ }
+
+ function getPrimaryQuizBattleDomeCompAt(px, py) {
+ const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py);
+ if (!arr.length) return null;
+ return Math.min.apply(null, arr);
+ }
+
+ function quizBattleFootprintOnDomePlay(md, px, py) {
+ const dome = md && md.quizBattleDomeArea;
+ if (!dome) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (dome[ty] && dome[ty][tx] === 1) return true;
+ }
+ return false;
+ }
+
+ /** โดมใต้เท้า หรือช่องติดกัน (8 ทิศ) — ให้กด E / เด้งคำถามได้แม้ยืนข้างสถานี */
+ function getNearestQuizBattleDomeCompAt(px, py) {
+ const direct = getPrimaryQuizBattleDomeCompAt(px, py);
+ if (direct != null) return direct;
+ if (!mapData || !isQuizBattle()) return null;
+ const dome = mapData.quizBattleDomeArea;
+ const comp = mapData.quizBattleDomeComp;
+ if (!dome) return null;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let best = null;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [1, -1], [-1, -1]]) {
+ const nx = tx + dx, ny = ty + dy;
+ if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
+ if (!dome[ny] || dome[ny][nx] !== 1) continue;
+ const cid = comp && comp[ny] && comp[ny][nx] ? comp[ny][nx] : 1;
+ if (best == null || cid < best) best = cid;
+ }
+ }
+ return best;
+ }
+
+ function showQuizBattleMcqModal(q) {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ const textEl = document.getElementById('quiz-battle-mcq-text');
+ if (!ov || !textEl || !q) return;
+ const titleEl = document.getElementById('quiz-battle-mcq-title');
+ if (titleEl) titleEl.textContent = 'QUESTION #' + (quizBattleModalCompId || '');
+ textEl.textContent = q.text || '';
+ const labels = ['A', 'B', 'C'];
+ for (let i = 0; i < 3; i++) {
+ const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!btn) continue;
+ const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : '';
+ const letterEl = btn.querySelector('.qbc-letter');
+ const txtEl = btn.querySelector('.qbc-text');
+ const markEl = btn.querySelector('.qbc-mark');
+ if (letterEl) letterEl.textContent = labels[i];
+ if (txtEl) txtEl.textContent = ch;
+ if (markEl) { markEl.removeAttribute('src'); markEl.alt = ''; }
+ btn.classList.remove('is-correct', 'is-wrong', 'is-locked');
+ btn.disabled = false;
+ }
+ quizBattleChoiceLocked = false;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ }
+
+ function trySubmitQuizBattleChoice(idx) {
+ if (quizBattleChoiceLocked) return;
+ if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return;
+ const compId = quizBattleModalCompId;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false);
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ const qIdx = quizBattleQuestionIndexForCompPlay(compId, pool.length);
+ const q = pool[qIdx];
+ if (!q) return;
+ const ok = Number(q.correctIndex) === Number(idx);
+ /* ตอบครั้งเดียวจบ — ผิดก็ "ผ่าน" และจำว่าผิด (ไม่ให้ตอบซ้ำ) · log ใช้ทำ MY RESULT (O/X) + accuracy */
+ quizBattleAnswerLog.set(compId, {
+ qIndex: qIdx,
+ firstTry: ok,
+ correct: ok,
+ submittedIdx: idx,
+ q: q,
+ });
+ quizBattlePassedComps.add(compId); /* ผ่านโดมนี้แล้ว (ถูกหรือผิดก็ผ่าน — ปลดล็อกเส้นทาง) */
+ /* โชว์ผลถูก/ผิดบนตัวเลือกทันที (q-correct.png / q-wrong.png) แล้วล็อกกันกดซ้ำ */
+ quizBattleChoiceLocked = true;
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (ov) {
+ ov.querySelectorAll('.quiz-battle-choice').forEach((b) => { b.classList.add('is-locked'); b.disabled = true; });
+ const setMark = (i, kind) => {
+ const b = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
+ if (!b) return;
+ b.classList.add(kind === 'correct' ? 'is-correct' : 'is-wrong');
+ const m = b.querySelector('.qbc-mark');
+ if (m) m.src = BASE + '/img/qb-ui/' + (kind === 'correct' ? 'q-correct.png' : 'q-wrong.png');
+ };
+ if (ok) {
+ setMark(idx, 'correct');
+ } else {
+ setMark(idx, 'wrong');
+ setMark(Number(q.correctIndex), 'correct'); /* เฉลยคำตอบที่ถูก */
+ }
+ }
+ if (ok) {
+ quizBattleAnsweredComps.add(compId);
+ if (typeof socket !== 'undefined' && socket && socket.connected) {
+ try { socket.emit('quizbattle-solve', { compId: compId }, function () {}); } catch (e) { /* ignore */ }
+ }
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ submitQuizBattleLeaderboardScore();
+ flashQuizBattleFeedback('ถูกต้อง · +1 · ทางไปข้างหน้าเปิดแล้ว', true);
+ } else {
+ flashQuizBattleFeedback('ตอบผิด — ผ่านได้เลย (บันทึกว่าผิด) ไม่ต้องตอบซ้ำ', false);
+ }
+ saveQuizBattleProgress();
+ /* ปิด popup แล้วเช็คจบ (ทั้งถูกและผิด) — ผิดหน่วงนานขึ้นให้เห็นเฉลยก่อน */
+ setTimeout(() => {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ const totalComps = quizBattleTotalCompsPlay();
+ if (!quizBattleCompletedShown && totalComps > 0 && quizBattlePassedComps.size >= totalComps) {
+ quizBattleCompletedShown = true;
+ setTimeout(openQuizBattleCompleted, 280);
+ }
+ }, ok ? 950 : 1300);
+ }
+
+ /**
+ * ชื่อผู้เล่นสำหรับ leaderboard/ranking — ผูกกับชื่อที่ตั้งใน Main-Lobby
+ * (localStorage 'roomLobbyDisplayName' → 'playerName' ผ่าน readStoredDisplayName / jdReadDisplayName)
+ * ก่อน fallback ไป me.nickname / ?nick= เพื่อให้ตรงกับโปรไฟล์เดียวกันทุกหน้า
+ */
+ function quizBattlePlayerDisplayName() {
+ let stored = '';
+ try {
+ if (typeof window.jdReadDisplayName === 'function') {
+ const v = String(window.jdReadDisplayName() || '').trim();
+ if (v && v !== 'MONE') stored = v; /* 'MONE' = ค่า default เมื่อยังไม่ตั้งชื่อ → ไม่ถือว่าตั้งแล้ว */
+ }
+ } catch (e) { /* ignore */ }
+ if (!stored) stored = readStoredDisplayName();
+ const name = stored || (me && me.nickname) || nick || 'ผู้เล่น';
+ return String(name || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น';
+ }
+
+ /** ส่งคะแนน (จำนวนตอบถูก) ขึ้น global leaderboard ของห้อง/หมวด */
+ function submitQuizBattleLeaderboardScore() {
+ if (!isQuizBattle() || !socket || !socket.connected) return;
+ const roomNo = quizBattleRoomNoPlay();
+ if (roomNo <= 0) return;
+ const score = quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0;
+ try {
+ socket.emit('qb-leaderboard-submit', {
+ roomId: 'qbroom' + roomNo,
+ name: quizBattlePlayerDisplayName(),
+ score: score,
+ characterId: me.characterId || getPlayCharacterId() || '',
+ });
+ } catch (e) { /* ignore */ }
+ quizBattleScoreSubmittedAt = Date.now();
+ }
+
+ /* ===== Quiz Battle: progress persistence (resume ต่อจากเดิม) ===== */
+ function quizBattleProgressKey() {
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ return 'qbProgress:' + roomId + ':' + quizBattlePlayerDisplayName();
+ }
+ function saveQuizBattleProgress() {
+ if (!isQuizBattle()) return;
+ try {
+ const passed = [];
+ quizBattleAnswerLog.forEach((v, c) => {
+ passed.push({ c: c, qi: v.qIndex, ft: v.firstTry ? 1 : 0, ok: v.correct ? 1 : 0, idx: v.submittedIdx });
+ });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ const data = {
+ passed: passed,
+ pos: (me && Number.isFinite(me.x) && Number.isFinite(me.y)) ? { x: me.x, y: me.y } : null,
+ elapsed: Math.max(0, Math.round(elapsed)),
+ ts: Date.now(),
+ };
+ localStorage.setItem(quizBattleProgressKey(), JSON.stringify(data));
+ } catch (e) { /* ignore */ }
+ }
+ function loadQuizBattleProgress() {
+ try {
+ const raw = localStorage.getItem(quizBattleProgressKey());
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) { return null; }
+ }
+ /** คืนสถานะที่เคยเล่น (โดมที่ผ่าน/คำตอบ/เวลา) + ตำแหน่งล่าสุด — เรียกใน setupPlayQuizBattleUi */
+ function restoreQuizBattleProgress() {
+ const data = loadQuizBattleProgress();
+ if (!data || !Array.isArray(data.passed)) return;
+ data.passed.forEach((p) => {
+ if (!p || !(p.c > 0)) return;
+ quizBattleAnswerLog.set(p.c, {
+ qIndex: (p.qi != null ? p.qi : 0),
+ firstTry: !!p.ft,
+ correct: !!p.ok,
+ submittedIdx: p.idx,
+ q: null, /* q ผูกใหม่จาก pool ตอนเปิด MY RESULT */
+ });
+ quizBattlePassedComps.add(p.c);
+ if (p.ok) quizBattleAnsweredComps.add(p.c);
+ });
+ /* เวลาเดินต่อ (offset session start ด้วยเวลาที่เคยใช้) */
+ if (data.elapsed > 0) {
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleSessionStartMs = nowMs - data.elapsed;
+ }
+ /* sync score HUD + leaderboard ให้ตรงกับที่เคยทำ */
+ if (myId != null) {
+ if (!playLiveQuizScores) playLiveQuizScores = {};
+ playLiveQuizScores[myId] = quizBattleAnsweredComps.size;
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ /* ย้ายตัวไปจุดเดิม (ถ้ายังเดินได้) แล้วบอกเซิร์ฟเวอร์ */
+ if (data.pos && Number.isFinite(data.pos.x) && Number.isFinite(data.pos.y)) {
+ const px = data.pos.x, py = data.pos.y;
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle() || !me) return;
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — อย่าเด้งกลับจุดเดิม */
+ if (typeof canWalkLikeLobby === 'function' && !canWalkLikeLobby(px, py)) return;
+ me.x = px; me.y = py;
+ if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ draw();
+ } catch (e) { /* ignore */ }
+ }, 350);
+ }
+ /* เข้ามาแล้วตอบครบทุกข้ออยู่แล้ว (resume) → ไปจุดจบเลย ไม่ต้องตอบใหม่ */
+ setTimeout(() => {
+ try {
+ if (!isQuizBattle()) return;
+ const total = quizBattleTotalCompsPlay();
+ if (total > 0 && quizBattlePassedComps.size >= total) {
+ quizBattleCompletedShown = true;
+ moveToQuizBattleFinishPlay();
+ }
+ } catch (e) { /* ignore */ }
+ }, 600);
+ }
+
+ /* ===== Quiz Battle: MY RESULT + RANKING popups (Stage 2) ===== */
+ function qbEscHtml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+ function qbFmtDuration(ms) {
+ const s = Math.max(0, Math.round((ms || 0) / 1000));
+ const m = Math.floor(s / 60), ss = s % 60;
+ if (m >= 60) { const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; }
+ if (m > 0) return m + 'm ' + ss + 's';
+ return ss + ' s';
+ }
+ /** จำนวนโดม (สถานีคำถาม) ทั้งหมดของแมป = ค่า comp สูงสุดใน quizBattleDomeComp */
+ function quizBattleTotalCompsPlay() {
+ const md = mapData;
+ if (md && md.quizBattleDomeComp && Array.isArray(md.quizBattleDomeComp)) {
+ let mx = 0;
+ const dc = md.quizBattleDomeComp;
+ for (let y = 0; y < dc.length; y++) {
+ const row = dc[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { const v = row[x] | 0; if (v > mx) mx = v; }
+ }
+ if (mx > 0) return mx;
+ }
+ return quizBattleMcqPool.length || 0;
+ }
+ function qbSetText(id, txt) { const el = document.getElementById(id); if (el) el.textContent = txt; }
+ function qbAvatarSrcForCharacter(cid) {
+ if (!cid) return '';
+ try { ensurePlayLobbyIdleAvatarImage(cid); } catch (e) { /* ignore */ }
+ const src = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (src) return src;
+ const im = getCharacterImg(cid, 'down');
+ return (im && im.src) ? im.src : '';
+ }
+
+ function openQuizBattleMyResult() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-myresult-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ const passed = quizBattlePassedComps ? quizBattlePassedComps.size : 0;
+ let firstCorrect = 0, attempted = 0;
+ quizBattleAnswerLog.forEach((v) => { attempted++; if (v.firstTry) firstCorrect++; });
+ const accPct = attempted ? Math.round((firstCorrect / attempted) * 100) : 0;
+ const progPct = total ? Math.round((passed / total) * 100) : 0;
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-mr-accuracy', accPct + '%');
+ qbSetText('qb-mr-accuracy-sub', '(' + firstCorrect + '/' + attempted + ')');
+ qbSetText('qb-mr-progress', progPct + '%');
+ qbSetText('qb-mr-progress-sub', '(' + passed + '/' + total + ')');
+ qbSetText('qb-mr-time', qbFmtDuration(elapsed));
+ const grid = document.getElementById('qb-mr-grid');
+ if (grid) {
+ const cells = Math.max(total, attempted, 0);
+ let html = '';
+ for (let c = 1; c <= cells; c++) {
+ const log = quizBattleAnswerLog.get(c);
+ let cls = 'qb-mr-cell', mark = '';
+ if (!log) { cls += ' is-unanswered'; }
+ else if (log.correct) { cls += ' is-correct'; mark = ' '; }
+ else { cls += ' is-wrong'; mark = ' '; }
+ html += '' + c + ' ' + mark + '
';
+ }
+ grid.innerHTML = html;
+ }
+ const det = document.getElementById('qb-mr-detail');
+ if (det) {
+ const entries = Array.from(quizBattleAnswerLog.entries()).sort((a, b) => a[0] - b[0]);
+ if (!entries.length) {
+ det.innerHTML = 'ยังไม่ได้ตอบคำถาม — เดินเข้าโดมแล้วกด E เพื่อเริ่ม
';
+ } else {
+ const letters = ['A', 'B', 'C'];
+ let html = '';
+ entries.forEach(function (pair) {
+ const cid = pair[0], log = pair[1];
+ const q = log.q || quizBattleMcqPool[log.qIndex] || {};
+ const choices = Array.isArray(q.choices) ? q.choices : [];
+ const markImg = log.correct ? 'q-correct.png' : 'q-wrong.png';
+ html += '';
+ html += '
ข้อ ' + cid + '
';
+ html += '
' + qbEscHtml(q.text || '') + '
';
+ choices.forEach(function (ch, i) {
+ let ocls = 'qb-mr-q-opt';
+ if (i === Number(q.correctIndex)) ocls += ' is-correct';
+ else if (i === Number(log.submittedIdx) && !log.correct) ocls += ' is-chosen-wrong';
+ html += '
' + letters[i] + '. ' + qbEscHtml(ch) + '
';
+ });
+ html += '
';
+ });
+ det.innerHTML = html;
+ }
+ det.scrollTop = 0;
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ function renderQuizBattleRanking(data) {
+ const top = (data && Array.isArray(data.top)) ? data.top : [];
+ const meRow = data && data.me ? data.me : null;
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) {
+ const order = [1, 0, 2]; /* แสดง 2-1-3 */
+ let html = '';
+ order.forEach(function (idx) {
+ const e = top[idx];
+ if (!e) return;
+ const rank = idx + 1;
+ const av = qbAvatarSrcForCharacter(e.characterId);
+ html += '';
+ html += '
' + (av ? '
' : '') + '
';
+ html += '
' + rank + '
';
+ html += '
' + qbEscHtml(e.name || '-') + '
';
+ html += '
' + (e.score | 0) + '
';
+ html += '
';
+ });
+ podium.innerHTML = html || 'ยังไม่มีคะแนน
';
+ }
+ const list = document.getElementById('qb-rank-list');
+ if (list) {
+ let html = '';
+ for (let i = 3; i < top.length && i < 50; i++) {
+ const e = top[i];
+ html += '' + (e.rank || (i + 1)) + ' '
+ + '' + qbEscHtml(e.name || '-') + ' '
+ + '' + (e.score | 0) + '
';
+ }
+ list.innerHTML = html;
+ }
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) {
+ if (meRow && (meRow.rank || 0) > 0) {
+ meBox.innerHTML = '' + (meRow.rank || '-') + ' '
+ + '' + qbEscHtml(meRow.name || 'คุณ') + ' (คุณ) '
+ + '' + (meRow.score | 0) + '
';
+ } else {
+ meBox.innerHTML = '';
+ }
+ }
+ }
+
+ function openQuizBattleRanking() {
+ if (!isQuizBattle()) return;
+ const pop = document.getElementById('qb-ranking-popup');
+ if (!pop) return;
+ /* ส่งคะแนนล่าสุดก่อน เพื่อให้อันดับตัวเองเป็นปัจจุบัน */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ const list = document.getElementById('qb-rank-list');
+ if (list) list.innerHTML = 'กำลังโหลดอันดับ…
';
+ const podium = document.getElementById('qb-rank-podium');
+ if (podium) podium.innerHTML = '';
+ const meBox = document.getElementById('qb-rank-me');
+ if (meBox) meBox.innerHTML = '';
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ renderQuizBattleRanking(resp || {});
+ });
+ } catch (e) {
+ if (list) list.innerHTML = 'โหลดอันดับไม่สำเร็จ
';
+ }
+ } else if (list) {
+ list.innerHTML = 'ยังไม่ได้เชื่อมต่อเซิร์ฟเวอร์
';
+ }
+ }
+
+ function closeQuizBattlePopup(which) {
+ let id = 'qb-myresult-popup';
+ if (which === 'ranking') id = 'qb-ranking-popup';
+ else if (which === 'completed') id = 'qb-completed-popup';
+ const pop = document.getElementById(id);
+ if (!pop) return;
+ pop.classList.add('is-hidden');
+ pop.setAttribute('aria-hidden', 'true');
+ }
+
+ /** popup "Completed" (popup-result.png) — สรุป Correct / Time Taken / My Rank เมื่อตอบครบทุกโดม */
+ /** สุ่มช่องในโซนจุดจบ (quizBattleFinishArea) เป็นพิกัด tile — ไม่มี = null (ให้ผู้เล่นกระจายตัว ไม่กองจุดเดียว) */
+ function quizBattleFinishRandomCellPlay() {
+ const md = mapData;
+ const fa = md && md.quizBattleFinishArea;
+ if (!Array.isArray(fa)) return null;
+ const cells = [];
+ for (let y = 0; y < fa.length; y++) {
+ const row = fa[y]; if (!row) continue;
+ for (let x = 0; x < row.length; x++) { if (row[x] === 1) cells.push([x, y]); }
+ }
+ if (!cells.length) return null;
+ const pick = cells[Math.floor(Math.random() * cells.length)];
+ /* สุ่มตำแหน่งย่อยในช่องด้วย เพื่อไม่ให้ซ้อนกันเป๊ะแม้สุ่มได้ช่องเดียวกัน */
+ const jitter = () => 0.25 + Math.random() * 0.5;
+ return { x: pick[0] + jitter(), y: pick[1] + jitter() };
+ }
+ /** ตอบครบทุกข้อ → ย้ายผู้เล่นไปจุดจบที่ตั้งไว้ใน Editor (สุ่มช่อง) */
+ function moveToQuizBattleFinishPlay() {
+ const c = quizBattleFinishRandomCellPlay();
+ if (!c || !me) return;
+ quizBattleFinishLock = true; /* ล็อก: หลังจากนี้ห้าม echo/restore เด้งกลับ */
+ me.x = c.x; me.y = c.y;
+ playPath = [];
+ try { if (socket && socket.connected) socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (e) { /* ignore */ }
+ try { draw(); } catch (e) { /* ignore */ }
+ }
+
+ function openQuizBattleCompleted() {
+ if (!isQuizBattle()) return;
+ moveToQuizBattleFinishPlay(); /* ย้ายไปจุดจบที่ตั้งไว้ (ถ้ามี) */
+ const pop = document.getElementById('qb-completed-popup');
+ if (!pop) return;
+ const total = quizBattleTotalCompsPlay();
+ let firstCorrect = 0;
+ quizBattleAnswerLog.forEach((v) => { if (v.firstTry) firstCorrect++; });
+ const nowMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const elapsed = quizBattleSessionStartMs ? (nowMs - quizBattleSessionStartMs) : 0;
+ qbSetText('qb-cp-correct', firstCorrect + ' / ' + total);
+ qbSetText('qb-cp-time', qbFmtDuration(elapsed));
+ qbSetText('qb-cp-rank', '—');
+ /* ส่งคะแนนล่าสุด แล้วดึงอันดับมาแสดง My Rank */
+ try { submitQuizBattleLeaderboardScore(); } catch (e) { /* ignore */ }
+ const roomNo = quizBattleRoomNoPlay();
+ const roomId = 'qbroom' + (roomNo > 0 ? roomNo : 1);
+ const myName = quizBattlePlayerDisplayName();
+ if (socket && socket.connected) {
+ try {
+ socket.emit('qb-leaderboard-get', { roomId: roomId, name: myName }, function (resp) {
+ const rank = resp && resp.me && resp.me.rank ? ('#' + resp.me.rank) : '—';
+ qbSetText('qb-cp-rank', rank);
+ });
+ } catch (e) { /* ignore */ }
+ }
+ pop.classList.remove('is-hidden');
+ pop.setAttribute('aria-hidden', 'false');
+ }
+
+ /** หา comp โดมที่ตัวละครเหยียบอยู่ — เช็คหลายจุด + ช่องติดโดม ให้เด้งง่ายเมื่อถึงสถานี */
+ function quizBattleDomeAtMe() {
+ if (!isQuizBattle()) return null;
+ const pts = [[me.x, me.y], [me.x, me.y + 0.45], [me.x, me.y - 0.25], [me.x + 0.35, me.y], [me.x - 0.35, me.y]];
+ for (let i = 0; i < pts.length; i++) {
+ const c = getNearestQuizBattleDomeCompAt(pts[i][0], pts[i][1]);
+ if (c != null) return c;
+ }
+ return getNearestQuizBattleDomeCompAt(me.x, me.y);
+ }
+
+ /** ZEP-style: เหยียบสถานี (โดม) ที่ยังไม่ตอบ → เปิดคำถามอัตโนมัติ (ครั้งเดียวต่อการเข้า · เดินออกแล้วกลับมาใหม่ถึงเด้งอีก) */
+ function maybeAutoOpenQuizBattle() {
+ if (!isQuizBattle() || myId == null) return;
+ const compNow = quizBattleDomeAtMe();
+ 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 (quizBattlePassedComps.has(compNow)) return;
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถาม — บันทึกใน Admin → Quiz Battle แล้วรีเฟรช', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compNow, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compNow;
+ showQuizBattleMcqModal(q);
+ }
+
+ function qbRoundRect(ctx, x, y, w, h, r) {
+ r = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ }
+ /** หีบบนสถานีคำถาม — ตอบถูก=เขียว+ติ๊ก / ตอบผิด(ผ่านแล้ว)=แดง+กากบาท / ยังไม่ตอบ=ล็อก(น้ำตาล+กุญแจ) + เลขสถานี */
+ function drawQuizBattleChest(ctx, cx, cy, size, done, num, wrong) {
+ const w = size * 0.84, h = size * 0.64;
+ const x0 = cx - w / 2, y0 = cy - h / 2;
+ ctx.save();
+ ctx.shadowColor = done ? (wrong ? 'rgba(248,113,128,0.95)' : 'rgba(52,211,153,0.95)') : 'rgba(255,200,70,0.95)';
+ ctx.shadowBlur = Math.max(4, size * 0.3);
+ ctx.fillStyle = done ? (wrong ? '#8a3340' : '#3f7d57') : '#8a5a2b';
+ qbRoundRect(ctx, x0, y0 + h * 0.34, w, h * 0.66, size * 0.08); ctx.fill();
+ ctx.fillStyle = done ? (wrong ? '#b3455a' : '#57a875') : '#a86f33';
+ qbRoundRect(ctx, x0, y0, w, h * 0.44, size * 0.1); ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = done ? (wrong ? '#ffd6dc' : '#d6f5e3') : '#f1c66a';
+ ctx.fillRect(x0, y0 + h * 0.30, w, h * 0.10);
+ ctx.fillRect(cx - size * 0.045, y0, size * 0.09, h);
+ if (done && wrong) {
+ ctx.strokeStyle = '#fff0f2'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.12, cy - size * 0.10); ctx.lineTo(cx + size * 0.12, cy + size * 0.12);
+ ctx.moveTo(cx + size * 0.12, cy - size * 0.10); ctx.lineTo(cx - size * 0.12, cy + size * 0.12);
+ ctx.stroke();
+ } else if (done) {
+ ctx.strokeStyle = '#eafff3'; ctx.lineWidth = Math.max(2, size * 0.08); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(cx - size * 0.13, cy + size * 0.04);
+ ctx.lineTo(cx - size * 0.02, cy + size * 0.15);
+ ctx.lineTo(cx + size * 0.17, cy - size * 0.12);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = '#f1c66a'; ctx.lineWidth = Math.max(2, size * 0.05);
+ ctx.beginPath(); ctx.arc(cx, cy + h * 0.04, size * 0.085, Math.PI, 0); ctx.stroke();
+ ctx.fillStyle = '#f1c66a';
+ qbRoundRect(ctx, cx - size * 0.085, cy + h * 0.04, size * 0.17, size * 0.13, size * 0.03); ctx.fill();
+ }
+ if (num) {
+ ctx.fillStyle = 'rgba(8,12,28,0.82)';
+ ctx.beginPath(); ctx.arc(cx, y0 - size * 0.14, size * 0.17, 0, Math.PI * 2); ctx.fill();
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold ' + Math.max(9, size * 0.23) + 'px Kanit, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillText(String(num), cx, y0 - size * 0.14);
+ ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
+ }
+ ctx.restore();
+ }
+
+ function tryOpenQuizBattleFromKey() {
+ if (!isQuizBattle() || !mapData || myId == null) return;
+ const compId = quizBattleDomeAtMe();
+ if (compId == null) {
+ flashQuizBattleFeedback('เข้าใกล้สถานีคำถาม (หีบ) แล้วกด E', false);
+ return;
+ }
+ if (quizBattlePassedComps.has(compId)) {
+ flashQuizBattleFeedback('โดมนี้ผ่านแล้ว', false);
+ return;
+ }
+ const pool = quizBattleMcqPool;
+ if (!pool.length) {
+ flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false);
+ return;
+ }
+ const q = pool[quizBattleQuestionIndexForCompPlay(compId, pool.length)];
+ if (!q) return;
+ quizBattleModalCompId = compId;
+ showQuizBattleMcqModal(q);
+ }
+
+ function pushSanitizedBattleMcqFromPayload(s) {
+ if (!s || !Array.isArray(s.battleQuizMcq)) return 0;
+ /* กรองเฉพาะคลังคำถามของหัวข้อห้องนี้ (mapData.quizBattleCategoryId เช่น topic1..topic10) — ไม่ระบุ = ใช้ทั้งหมด */
+ const roomCat = (mapData && mapData.quizBattleCategoryId) ? String(mapData.quizBattleCategoryId).trim() : '';
+ let n = 0;
+ for (let i = 0; i < s.battleQuizMcq.length; i++) {
+ const raw = s.battleQuizMcq[i];
+ if (!raw || !String(raw.text || '').trim()) continue;
+ if (roomCat && String(raw.categoryId || '').trim() !== roomCat) continue;
+ const ch = Array.isArray(raw.choices) ? raw.choices : [];
+ if (ch.length !== 3) continue;
+ const a = String(ch[0] || '').trim();
+ const b = String(ch[1] || '').trim();
+ const c = String(ch[2] || '').trim();
+ if (!a || !b || !c) continue;
+ let ci = Number(raw.correctIndex);
+ if (!Number.isFinite(ci)) ci = 0;
+ ci = Math.max(0, Math.min(2, Math.floor(ci)));
+ quizBattleMcqPool.push({
+ text: String(raw.text).trim(),
+ choices: [a, b, c],
+ correctIndex: ci,
+ });
+ n++;
+ }
+ return n;
+ }
+
+ /**
+ * โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node)
+ */
+ async function loadQuizBattleMcqPool() {
+ quizBattleMcqPool = [];
+ const bust = '_=' + Date.now();
+ const tryUrls = [
+ BASE + '/api/quiz-settings?' + bust,
+ BASE + '/api-quiz-battle-mcq.php?' + bust,
+ ];
+ for (let u = 0; u < tryUrls.length; u++) {
+ try {
+ const r = await fetch(tryUrls[u], { cache: 'no-store' });
+ if (!r.ok) continue;
+ const s = await r.json();
+ if (pushSanitizedBattleMcqFromPayload(s) > 0) break;
+ } catch (e) { /* next url */ }
+ }
+ }
+
+ function resetQuizBattlePlayState() {
+ quizBattleMcqPool = [];
+ quizBattleAnsweredComps = new Set();
+ quizBattlePassedComps = new Set();
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+
+ function setupPlayQuizBattleUi() {
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ /* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */
+ if (ov) {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else ov.classList.remove('is-hidden');
+ }
+ const phaseEl = document.getElementById('quiz-game-phase-label');
+ if (phaseEl) phaseEl.textContent = 'Quiz Battle';
+ const qEl = document.getElementById('quiz-game-question');
+ if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C';
+ const tEl = document.getElementById('quiz-game-timer');
+ if (tEl) tEl.textContent = '';
+ const leg = document.getElementById('quiz-play-legend');
+ if (leg) {
+ leg.textContent = quizBattlePathModeActive(mapData)
+ ? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง'
+ : 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม';
+ }
+ quizBattleAnsweredComps = new Set();
+ quizBattleAnswerLog = new Map();
+ quizBattlePassedComps = new Set();
+ quizBattleUserZoomMul = 1;
+ quizBattleSessionStartMs = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ quizBattleModalCompId = null;
+ quizBattleMcqPool = [];
+ quizBattleCompletedShown = false;
+ quizBattleFinishLock = false;
+ hideQuizBattleMcqModal();
+ playLiveQuizScores = {};
+ if (myId != null) playLiveQuizScores[myId] = 0;
+ others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ /* คืนสถานะที่เคยเล่น (โดมที่ผ่าน/ตำแหน่งล่าสุด) — เล่นต่อจากเดิมไม่ต้องตอบซ้ำ */
+ restoreQuizBattleProgress();
+ loadQuizBattleMcqPool().then(() => {
+ const q2 = document.getElementById('quiz-game-question');
+ if (!q2) return;
+ if (!quizBattleMcqPool.length) {
+ q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)';
+ } else {
+ const q0 = quizBattleMcqPool[0];
+ const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : '';
+ const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : '';
+ q2.textContent = snippet
+ ? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C')
+ : ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C');
+ }
+ const ov2 = document.getElementById('quiz-game-overlay');
+ if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden');
+ });
+ }
+
+ function normalizeJumpSurvivePlatformAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurvivePlatformArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformArea = rows;
+ }
+
+ function normalizeJumpSurvivePlatformVariantAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea || [];
+ const src = md.jumpSurvivePlatformVariantArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const has = pa[y] && pa[y][x] === 1;
+ let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
+ if (has && (!Number.isFinite(v) || v < 1)) v = 1;
+ if (v > 3) v = 3;
+ if (!has) v = 0;
+ row.push(v);
+ }
+ rows.push(row);
+ }
+ md.jumpSurvivePlatformVariantArea = rows;
+ }
+
+ function jumpSurvivePlatformVariantIndexAtPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa || !pa[ty] || pa[ty][tx] !== 1) return 0;
+ const va = md.jumpSurvivePlatformVariantArea;
+ let v = va && va[ty] ? Math.floor(Number(va[ty][tx])) : 1;
+ if (!Number.isFinite(v) || v < 1) v = 1;
+ if (v > 3) v = 3;
+ return v;
+ }
+
+ function normalizeJumpSurviveHazardAreaInPlay(md) {
+ if (!md || md.gameType !== 'jump_survive') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.jumpSurviveHazardArea || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
+ rows.push(row);
+ }
+ md.jumpSurviveHazardArea = rows;
+ }
+
+ /** โซนตาย jump_survive — กริดคงที่ในโลก (ไม่เลื่อนกับแพลตฟอร์ม) · ช่องที่มีแพลตฟอร์มอยู่ด้วย = ไม่นับ hazard (ยืนบนแพลตฟอร์มชนะ) */
+ function jumpSurviveOverlapsHazardArea(md, left, top, right, bottom) {
+ const ha = md && md.jumpSurviveHazardArea;
+ if (!ha) return false;
+ const pa = md && md.jumpSurvivePlatformArea;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const y0 = Math.max(0, Math.floor(top / ts));
+ const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts));
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!ha[ty] || ha[ty][tx] !== 1) continue;
+ if (pa && pa[ty] && pa[ty][tx] === 1) continue;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function normalizeShooterSpawnSlotsInPlay(md) {
+ if (!md || (md.gameType !== 'space_shooter' && md.gameType !== 'jump_survive')) return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.shooterSpawnSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.shooterSpawnSlots = rows;
+ }
+
+ function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.shooterSpawnSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.shooterSpawnSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function buildSpaceShooterParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function applySpaceShooterSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const n = refs.length || 1;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = (idx % 6) + 1;
+ const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ cx = ((idx + 1) / (n + 1)) * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
+ }
+ cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
+ cy = clampSpaceShooterWorldCy(cy, mh);
+ ent.spaceShooterCx = cx;
+ ent.spaceShooterCy = cy;
+ ent.spaceShooterSlot = slot;
+ if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
+ ent.x = cx / ts - cw * 0.5;
+ ent.y = cy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'up';
+ });
+ }
+
+ function spaceShooterTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.spaceShooterTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playSpaceShooterMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 90;
+ }
+
+ function spaceShooterRemainingSecPlay() {
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null;
+ const lim = spaceShooterTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function endSpaceShooterTimeUp() {
+ if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay()) {
+ endSpaceShooterMissionRound('time_up');
+ return;
+ }
+ spaceShooterGameEnded = true;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterSpawnAccMs = 0;
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
+ msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function syncSpaceShooterFootFromShipCenter(ent) {
+ if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.spaceShooterCx / ts - cw * 0.5;
+ ent.y = ent.spaceShooterCy / ts - ch * 0.92;
+ }
+
+ /** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
+ function clampSpaceShooterWorldCy(cy, mhPx) {
+ if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
+ const edge = 20;
+ const lo = mhPx * 0.5;
+ const hi = Math.max(lo + 4, mhPx - edge);
+ return Math.max(lo, Math.min(hi, cy));
+ }
+
+ const SPACE_SHOOTER_ASTEROID_INTERVAL_MIN = 200;
+ /** ms ระหว่างเกิดอุกาบาต — แมปทับถ้าตั้ง ≥200 */
+ function spaceShooterAsteroidSpawnIntervalMsPlay() {
+ const mapI = Number(mapData && mapData.spaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(mapI) && mapI >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) {
+ return Math.min(10000, Math.floor(mapI));
+ }
+ const g = Number(playSpaceShooterAsteroidIntervalMs);
+ if (Number.isFinite(g) && g >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) return Math.min(10000, Math.floor(g));
+ return 1040;
+ }
+
+ function stepSpaceShooterPreviewBots(dt) {
+ if (spaceShooterGameEnded || !playBotsEnabled() || !mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const ts = tileSize;
+ const mw = (mapData.width || 20) * ts;
+ const mh = (mapData.height || 15) * ts;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.spaceShooterCx == null || o.spaceShooterEliminated) return;
+ let nearest = null, nd = 1e9;
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ if (!a) continue;
+ const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
+ if (d < nd) { nd = d; nearest = a; }
+ }
+ let vx = 0;
+ if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
+ else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
+ let vy = 0;
+ if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
+ vy = nearest.y > o.spaceShooterCy ? 1 : -1;
+ } else if (!nearest) {
+ vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
+ }
+ const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ o.spaceShooterCx = Math.max(padX, Math.min(mw - padX, o.spaceShooterCx + vx * spd * dt));
+ o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
+ syncSpaceShooterFootFromShipCenter(o);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
+ o.spaceShooterBotFireCd -= dt;
+ if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
+ o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
+ spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
+ }
+ });
+ }
+
+ function spaceShooterTickFrame() {
+ if (!mapData || mapData.gameType !== 'space_shooter') return;
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const now = performance.now();
+ const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
+ spaceShooterLastTickMs = now;
+ tickPlayScrollBg(dt);
+ spaceShooterTickAsteroidExplosions(dt);
+
+ if (spaceShooterGameEnded) {
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+ return;
+ }
+ const remSec = spaceShooterRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endSpaceShooterTimeUp();
+ return;
+ }
+
+ const interval = spaceShooterAsteroidSpawnIntervalMsPlay();
+ spaceShooterSpawnAccMs += dt * 1000;
+ while (spaceShooterSpawnAccMs >= interval) {
+ spaceShooterSpawnAccMs -= interval;
+ const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP);
+ const hp0 = maxHp;
+ spaceShooterAsteroids.push({
+ x: 36 + Math.random() * (mw - 72),
+ y: -45 - Math.random() * 90,
+ r: spaceShooterAsteroidRadiusFromHpPlay(hp0),
+ vy: 58 + Math.random() * 62,
+ maxHp: maxHp,
+ hp: hp0,
+ });
+ }
+
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
+ o.spaceShooterCx = (o.x + cw * 0.5) * ts;
+ o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
+ });
+
+ let vx = 0;
+ let vy = 0;
+ if (!isChatFocused()) {
+ if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
+ }
+ const moveSpd = 280;
+ const canPilotMe = !(isSpaceShooterMissionUiMapPlay() && me.spaceShooterEliminated);
+ if (canPilotMe) {
+ if (me.spaceShooterCx != null) {
+ const padX = spaceShooterShipEdgePadWorldPxPlay();
+ me.spaceShooterCx = Math.max(padX, Math.min(mw - padX, me.spaceShooterCx + vx * moveSpd * dt));
+ }
+ if (me.spaceShooterCy != null) {
+ me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
+ }
+ syncSpaceShooterFootFromShipCenter(me);
+ }
+
+ spaceShooterFireCd -= dt;
+ const autoFireOk = canPilotMe && !isChatFocused();
+ if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
+ spaceShooterFireCd = 0.21;
+ spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
+ }
+
+ stepSpaceShooterPreviewBots(dt);
+
+ for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
+ const b = spaceShooterBullets[i];
+ b.y += b.vy * dt;
+ if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
+ }
+ for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
+ const a = spaceShooterAsteroids[ai];
+ a.y += a.vy * dt;
+ if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
+ }
+
+ spaceShooterMissionResolveShipAsteroidHitsPlay();
+
+ for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
+ const b = spaceShooterBullets[bi];
+ let hit = -1;
+ for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
+ const a = spaceShooterAsteroids[ai];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const rr = (a.r + 7) * (a.r + 7);
+ if (dx * dx + dy * dy <= rr) { hit = ai; break; }
+ }
+ if (hit >= 0) {
+ const a = spaceShooterAsteroids[hit];
+ const mh = Math.max(1, Math.floor(Number(a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
+ a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1);
+ spaceShooterBullets.splice(bi, 1);
+ const add = 5;
+ if (b.ownerId === myId) {
+ me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
+ spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
+ }
+ }
+ if (a.hp <= 0) {
+ spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
+ spaceShooterAsteroids.splice(hit, 1);
+ }
+ /** ขนาดคงที่จนแตก — ไม่ย่อ r ตาม HP ที่เหลือ (สลับสปรายต์ผ่าน spaceShooterAsteroidLiveSpriteIndexPlay) */
+ }
+ }
+
+ const wallClock = Date.now();
+ for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
+ /* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
+ if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
+ }
+
+ me.direction = 'up';
+ me.isWalking = vx !== 0 || vy !== 0;
+ const tEmit = Date.now();
+ if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
+ spaceShooterLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
+ });
+ }
+ }
+
+ function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isSpaceShooter()) return;
+ const refs = buildSpaceShooterParticipantRefsPlay();
+ const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
+
+ const astUrls = playSpaceShooterAsteroidSpriteUrls;
+
+ for (let i = 0; i < spaceShooterAsteroids.length; i++) {
+ const a = spaceShooterAsteroids[i];
+ const [sx, sy] = worldToScreen(a.x, a.y);
+ const sr = Math.max(6, a.r * zDraw);
+ const si = spaceShooterAsteroidLiveSpriteIndexPlay(a);
+ let liveUrl = (astUrls && astUrls[si]) ? String(astUrls[si]).trim() : '';
+ if (!liveUrl && si > 0 && astUrls && astUrls[0]) liveUrl = String(astUrls[0]).trim();
+ const liveRec = liveUrl ? ensureGauntletAssetImage(liveUrl) : null;
+ const liveAstImg = liveRec && liveRec.ready && liveRec.img && liveRec.img.naturalWidth > 0 ? liveRec.img : null;
+ if (liveAstImg) {
+ const iw = liveAstImg.naturalWidth;
+ const ih = liveAstImg.naturalHeight;
+ const diam = sr * 2.15;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(liveAstImg, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ } else {
+ const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
+ grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
+ grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
+ grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
+ ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
+ ctx.shadowBlur = sr * 0.45;
+ ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+ }
+
+ if (astUrls && astUrls.length >= 2) {
+ for (let ei = 0; ei < spaceShooterAsteroidExplosions.length; ei++) {
+ const ex = spaceShooterAsteroidExplosions[ei];
+ const u = astUrls[ex.fi + 1];
+ if (!u) continue;
+ const rec = ensureGauntletAssetImage(u);
+ const img = rec && rec.ready && rec.img && rec.img.naturalWidth > 0 ? rec.img : null;
+ if (!img) continue;
+ const [sx, sy] = worldToScreen(ex.x, ex.y);
+ const sr = Math.max(6, ex.r * zDraw);
+ const iw = img.naturalWidth;
+ const ih = img.naturalHeight;
+ const diam = sr * 2.4;
+ const sc = Math.min(diam / iw, diam / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(img, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
+ }
+ }
+
+ for (let i = 0; i < spaceShooterBullets.length; i++) {
+ const b = spaceShooterBullets[i];
+ for (let k = 0; k < 4; k++) {
+ const t = k / 4;
+ const yy = b.y + t * 22;
+ const [bx, by] = worldToScreen(b.x, yy);
+ ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ const shipBody = spaceShooterShipBodyScreenPxPlay(zDraw);
+ const bodyW = shipBody.bodyW;
+ const bodyH = shipBody.bodyH;
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
+ const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
+ const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
+ const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated);
+ if (elimShip) return;
+ const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1)));
+ const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
+ const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null;
+ const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null;
+ let shipDrawW = bodyW * 2.2;
+ let shipDrawH = bodyH * 2.2;
+ if (shipImg) {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const iw = shipImg.naturalWidth;
+ const ih = shipImg.naturalHeight;
+ const maxW = bodyW * 2.4;
+ const maxH = bodyH * 2.4;
+ const sc = Math.min(maxW / iw, maxH / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ shipDrawW = dw;
+ shipDrawH = dh;
+ ctx.drawImage(shipImg, -dw * 0.5, -dh * 0.5, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(sx, sy);
+ if (elimShip) ctx.globalAlpha = 0.38;
+ ctx.fillStyle = col;
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
+ ctx.lineWidth = Math.max(1.2, zDraw);
+ ctx.beginPath();
+ ctx.moveTo(0, -bodyH * 0.55);
+ ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
+ ctx.lineTo(0, bodyH * 0.22);
+ ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
+ ctx.lineTo(0, bodyH * 0.72);
+ ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ const hits = Math.max(0, Math.min(3, Number(ent.spaceShooterHits) || 0));
+ if (hits > 0 && playSpaceShooterShipDamageOverlayUrls && playSpaceShooterShipDamageOverlayUrls.length) {
+ const oUrl = (playSpaceShooterShipDamageOverlayUrls[hits - 1] || '').trim();
+ if (oUrl) {
+ const oRec = ensureGauntletAssetImage(oUrl);
+ const oImg = oRec && oRec.ready && oRec.img && oRec.img.naturalWidth > 0 ? oRec.img : null;
+ if (oImg) {
+ ctx.save();
+ if (elimShip) ctx.globalAlpha = 0.38;
+ const ow = oImg.naturalWidth;
+ const oh = oImg.naturalHeight;
+ const scO = Math.min(shipDrawW / ow, shipDrawH / oh);
+ const odw = ow * scO;
+ const odh = oh * scO;
+ ctx.drawImage(oImg, sx - odw * 0.5, sy - odh * 0.5, odw, odh);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ const name = (ent.nickname || '').slice(0, 10);
+ if (name) {
+ ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
+ ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(name, sx, sy - bodyH * 0.62);
+ ctx.fillText(name, sx, sy - bodyH * 0.62);
+ ctx.textAlign = 'left';
+ }
+ });
+
+ for (let i = 0; i < spaceShooterPopups.length; i++) {
+ const p = spaceShooterPopups[i];
+ if (wallNow >= p.until) continue;
+ const [px, py] = worldToScreen(p.x, p.y);
+ const dur = 700;
+ const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
+ ctx.save();
+ ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
+ ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = '#ffe066';
+ ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
+ ctx.lineWidth = 3;
+ ctx.strokeText(p.text || '+5', px, py - age * 18);
+ ctx.fillText(p.text || '+5', px, py - age * 18);
+ ctx.restore();
+ }
+ }
+
+ const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
+
+ function normalizeBalloonBossPlayerSlotsInPlay(md) {
+ if (!md || md.gameType !== 'balloon_boss') return;
+ const w = md.width || 20, h = md.height || 15;
+ const src = md.balloonBossPlayerSlots || [];
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ const r = src[y];
+ const row = [];
+ for (let x = 0; x < w; x++) {
+ const v = r && r[x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
+ }
+ rows.push(row);
+ }
+ md.balloonBossPlayerSlots = rows;
+ }
+
+ function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
+ if (!md || !md.balloonBossPlayerSlots) return null;
+ const w = md.width || 20, h = md.height || 15;
+ const g = md.balloonBossPlayerSlots;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
+ }
+ }
+ return null;
+ }
+
+ function getBalloonBossBossWorldCenterPlay(md, ts) {
+ const bx = md && md.balloonBossBossSpawn;
+ if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
+ return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
+ }
+ const w = md.width || 20, h = md.height || 15;
+ return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
+ }
+
+ /**
+ * จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ)
+ * เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง)
+ */
+ function getBalloonBossBossLiveCenterPlay(md, ts) {
+ if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
+ const base = getBalloonBossBossWorldCenterPlay(md, ts);
+ const w = md.width || 20, h = md.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const tSec = balloonBossSessionStartMs > 0
+ ? (performance.now() - balloonBossSessionStartMs) / 1000
+ : performance.now() / 1000;
+ const ax = Math.min(mw * 0.19, 240);
+ const ay = Math.min(mh * 0.17, 200);
+ const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
+ const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
+ return clampBalloonBossWorld(cx, cy, mw, mh);
+ }
+
+ /** จุดโลกประมาณกลางกระจุกลูกโป่ง (ใช้ hit จากกระสุนบอส) — ไม่ใช่จุดเท้า */
+ function balloonBossBalloonClusterWorldPlay(ent, bossCx, bossCy) {
+ if (!ent || ent.balloonBossCx == null) return { cx: 0, cy: 0 };
+ const dx = ent.balloonBossCx - bossCx;
+ const dy = ent.balloonBossCy - bossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const ox = (dx / L) * 22;
+ const oy = (dy / L) * 12;
+ return {
+ cx: ent.balloonBossCx + ox,
+ cy: ent.balloonBossCy - 52 + oy * 0.35,
+ };
+ }
+
+ /** ผู้เล่นหลักเด้งกับวงชนบอส (บอสเป็นวงนิ่งใน world) */
+ function balloonBossPlayerBossElasticPlay(meEnt, bossCx, bossCy, bossCollideR, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rB = Math.max(30, bossCollideR * 0.9);
+ const dx = meEnt.balloonBossCx - bossCx;
+ const dy = meEnt.balloonBossCy - bossCy;
+ const dist = Math.hypot(dx, dy) || 1e-6;
+ const minD = rMe + rB;
+ if (dist >= minD) return;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.58;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ const cl = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl.cx;
+ meEnt.balloonBossCy = cl.cy;
+ }
+
+ /** ผู้เล่นหลักเด้งกับคนอื่น (บอทขยับรับแรงปะทะเล็กน้อย) */
+ function balloonBossPlayerElasticCollisionsPlay(meEnt, aliveRefs, mw, mh) {
+ if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
+ const rMe = 44;
+ const rO = 44;
+ const minD = rMe + rO - 2;
+ for (let ii = 0; ii < aliveRefs.length; ii++) {
+ const entry = aliveRefs[ii];
+ if (!entry || entry.ref === meEnt) continue;
+ const o = entry.ref;
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) continue;
+ const dx = meEnt.balloonBossCx - o.balloonBossCx;
+ const dy = meEnt.balloonBossCy - o.balloonBossCy;
+ const dist = Math.hypot(dx, dy);
+ if (!dist || dist >= minD) continue;
+ const nx = dx / dist;
+ const ny = dy / dist;
+ const push = (minD - dist) * 0.52;
+ meEnt.balloonBossCx += nx * push;
+ meEnt.balloonBossCy += ny * push;
+ const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
+ if (vn < 0) {
+ meEnt.balloonBossVelX -= 1.85 * vn * nx;
+ meEnt.balloonBossVelY -= 1.85 * vn * ny;
+ }
+ if (isPreviewBotId(entry.id)) {
+ const pushO = (minD - dist) * 0.48;
+ o.balloonBossCx -= nx * pushO;
+ o.balloonBossCy -= ny * pushO;
+ }
+ }
+ const cl2 = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
+ meEnt.balloonBossCx = cl2.cx;
+ meEnt.balloonBossCy = cl2.cy;
+ }
+
+ /** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */
+ function getBalloonBossOccupiedSlotNumbersPlay(md) {
+ if (!md || !md.balloonBossPlayerSlots) return [];
+ const g = md.balloonBossPlayerSlots;
+ const w = md.width || 20, h = md.height || 15;
+ const seen = new Set();
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const v = g[y] && g[y][x];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n));
+ }
+ }
+ return Array.from(seen).sort((a, b) => a - b);
+ }
+
+ function buildBalloonBossParticipantRefsPlay() {
+ const refs = [];
+ if (me) refs.push({ id: myId, ref: me });
+ const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ humanIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((id) => {
+ const o = others.get(id);
+ if (o) refs.push({ id, ref: o });
+ });
+ return refs.filter((r) => r.ref);
+ }
+
+ function balloonBossBalloonsStartPlay() {
+ const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
+ /** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */
+ return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3));
+ }
+
+ function balloonBossMaxHpPlay() {
+ const n = Number(mapData && mapData.balloonBossMaxHp);
+ return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
+ }
+
+ function balloonBossFireDelayMsPlay() {
+ const n = Number(mapData && mapData.balloonBossFireDelayMs);
+ return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
+ }
+
+ /** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
+ function balloonBossShipAccelPlay() {
+ const n = Number(mapData && mapData.balloonBossShipAccel);
+ return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
+ }
+
+ /** ความเร็วสูงสุดยาน (px/s) */
+ function balloonBossShipMaxSpeedPlay() {
+ const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
+ return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
+ }
+
+ /** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
+ function balloonBossShipDragPlay() {
+ const n = Number(mapData && mapData.balloonBossShipDrag);
+ return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
+ }
+
+ function balloonBossTimeLimitSecPlay() {
+ const t = Number(mapData && mapData.balloonBossTimeSec);
+ if (Number.isFinite(t) && t === 0) return 0;
+ if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
+ const g = Number(playBalloonBossMissionTimeSec);
+ if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
+ return 120;
+ }
+
+ function balloonBossRemainingSecPlay() {
+ const lim = balloonBossTimeLimitSecPlay();
+ if (lim <= 0) return null;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim;
+ if (!balloonBossSessionStartMs) return lim;
+ const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
+ return Math.max(0, Math.ceil(lim - elapsed));
+ }
+
+ function balloonBossTeamDamagePlay() {
+ let s = Math.max(0, me.balloonBossScore | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
+ return s;
+ }
+
+ /** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */
+ function balloonBossTeamBossDamagePlay() {
+ let s = Math.max(0, me.balloonBossBossDmg | 0);
+ others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); });
+ return s;
+ }
+
+ function syncBalloonBossFootFromCenter(ent) {
+ if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ }
+
+ function clampBalloonBossWorld(cx, cy, mw, mh) {
+ const e = 22;
+ return {
+ cx: Math.max(e, Math.min(mw - e, cx)),
+ cy: Math.max(e, Math.min(mh - e, cy)),
+ };
+ }
+
+ function applyBalloonBossSpawnLayoutPlay() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const mw = w * ts, mh = h * ts;
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const n = refs.length || 1;
+ const bStart = balloonBossBalloonsStartPlay();
+ const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData);
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((idx % 6) + 1);
+ const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
+ let cx, cy;
+ if (cen) {
+ cx = cen.cx;
+ cy = cen.cy;
+ } else {
+ const t = (idx + 1) / (n + 1);
+ cx = t * mw;
+ cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
+ }
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ ent.balloonBossSkinSlot = slot;
+ ent.balloonBossCx = cl.cx;
+ ent.balloonBossCy = cl.cy;
+ if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
+ if (typeof ent.balloonBossBossDmg !== 'number' || !Number.isFinite(ent.balloonBossBossDmg)) ent.balloonBossBossDmg = 0;
+ /** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */
+ ent.balloonBossBalloons = bStart;
+ if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
+ ent.balloonBossVelX = 0;
+ ent.balloonBossVelY = 0;
+ ent.x = ent.balloonBossCx / ts - cw * 0.5;
+ ent.y = ent.balloonBossCy / ts - ch * 0.92;
+ ent.tx = ent.x;
+ ent.ty = ent.y;
+ ent.direction = 'down';
+ if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) {
+ ent.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ });
+ }
+
+ function endBalloonBossGame(reason) {
+ if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
+ balloonBossGameEnded = true;
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossScorePopups = [];
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyMegaVirusMissionPanelImages();
+ beginBalloonBossMegaVirusResultFlashSequenceThenGcm(balloonBossBuildMissionPayloadPlay(reason), reason);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
+ }
+ msgEl.textContent = reason === 'victory'
+ ? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากคะแนนการยิงโดน'
+ : 'หมดเวลา — อันดับจากคะแนนการยิงโดน';
+ listEl.innerHTML = '';
+ const ranks = [];
+ if (myId != null) {
+ ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
+ }
+ others.forEach((o, id) => {
+ ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
+ });
+ ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)} pts`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ }
+
+ function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'balloon_boss') return;
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
+ const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
+ const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
+ const spd = 48;
+ let nx = o.balloonBossCx + wob * spd * dt;
+ let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
+ const cl = clampBalloonBossWorld(nx, ny, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ syncBalloonBossFootFromCenter(o);
+ o.direction = 'down';
+ o.tx = o.x;
+ o.ty = o.y;
+ if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
+ else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
+ if (o.balloonBossBotNextFire <= 0) {
+ o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const dx = bossCx - o.balloonBossCx;
+ const dy = bossCy - o.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ const bspd = 500 + Math.random() * 90;
+ balloonBossPendingShots.push({
+ releaseAt: performance.now() + delayMs,
+ sx: o.balloonBossCx, sy: o.balloonBossCy,
+ vx: (dx / L) * bspd,
+ vy: (dy / L) * bspd,
+ ownerId: bid,
+ });
+ }
+ });
+ }
+
+ function balloonBossTickFrame() {
+ if (!mapData || mapData.gameType !== 'balloon_boss') return;
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const ts = tileSize;
+ const mw = w * ts, mh = h * ts;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (!o || o.balloonBossEliminated) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
+ if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
+ const cx = (o.x + cw * 0.5) * ts;
+ const cy = (o.y + ch * 0.92) * ts;
+ const cl = clampBalloonBossWorld(cx, cy, mw, mh);
+ o.balloonBossCx = cl.cx;
+ o.balloonBossCy = cl.cy;
+ });
+ const now = performance.now();
+ const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
+ balloonBossLastTickMs = now;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const bossR = Math.max(36, ts * 1.15);
+ const maxHp = balloonBossMaxHpPlay();
+ const teamBossDmg = balloonBossTeamBossDamagePlay();
+
+ if (me.balloonBossEliminated) {
+ me.balloonBossVelX = 0;
+ me.balloonBossVelY = 0;
+ }
+ if (balloonBossGameEnded) {
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+ return;
+ }
+ const remSec = balloonBossRemainingSecPlay();
+ if (remSec != null && remSec <= 0) {
+ endBalloonBossGame('time');
+ return;
+ }
+ if (teamBossDmg >= maxHp) {
+ endBalloonBossGame('victory');
+ return;
+ }
+ const allRefsEarly = buildBalloonBossParticipantRefsPlay();
+ if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) {
+ endBalloonBossGame('all_dead');
+ return;
+ }
+
+ for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
+ balloonBossHitFx[hi].t -= dt;
+ if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
+ }
+ for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
+ const sp = balloonBossScorePopups[si];
+ sp.t -= dt;
+ if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
+ }
+
+ const aliveRefs = allRefsEarly.filter((e) => e.ref && !e.ref.balloonBossEliminated);
+
+ for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
+ const pnd = balloonBossPendingShots[pi];
+ if (now >= pnd.releaseAt) {
+ balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
+ balloonBossPendingShots.splice(pi, 1);
+ }
+ }
+
+ const bossFireEvery = 1.42;
+ balloonBossBossFireAcc += dt;
+ while (balloonBossBossFireAcc >= bossFireEvery) {
+ balloonBossBossFireAcc -= bossFireEvery;
+ if (aliveRefs.length) {
+ const ang = Math.random() * Math.PI * 2;
+ const spd = 88 + Math.random() * 72;
+ balloonBossBossBullets.push({
+ x: bossC.cx, y: bossC.cy,
+ vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
+ gy: 48 + Math.random() * 56,
+ });
+ }
+ }
+
+ function applyBalloonBossHitToEntity(ent) {
+ if (!ent || ent.balloonBossEliminated) return;
+ if (ent.balloonBossHitIframe > now) return;
+ ent.balloonBossHitIframe = now + 520;
+ ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
+ if (ent.balloonBossBalloons <= 0) {
+ ent.balloonBossEliminated = true;
+ ent.balloonBossBalloons = 0;
+ }
+ if (ent === me && socket) {
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossBalloons: me.balloonBossBalloons,
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ });
+ }
+ }
+
+ for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
+ const b = balloonBossBossBullets[bi];
+ const gy = typeof b.gy === 'number' && Number.isFinite(b.gy) ? b.gy : 62;
+ b.vy += gy * dt;
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
+ balloonBossBossBullets.splice(bi, 1);
+ continue;
+ }
+ let hit = false;
+ aliveRefs.forEach((entry) => {
+ if (hit) return;
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) return;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const dx = b.x - cc.cx, dy = b.y - cc.cy;
+ if (dx * dx + dy * dy <= (36 * 36)) {
+ hit = true;
+ applyBalloonBossHitToEntity(ent);
+ }
+ });
+ if (hit) balloonBossBossBullets.splice(bi, 1);
+ }
+
+ for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
+ const b = balloonBossPlayerBullets[i];
+ b.x += b.vx * dt;
+ b.y += b.vy * dt;
+ if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
+ balloonBossPlayerBullets.splice(i, 1);
+ continue;
+ }
+ let pHit = false;
+ for (let ji = 0; ji < aliveRefs.length; ji++) {
+ if (pHit) break;
+ const entry = aliveRefs[ji];
+ const ent = entry.ref;
+ if (!ent || ent.balloonBossCx == null) continue;
+ if (String(entry.id) === String(b.ownerId)) continue;
+ const canBalloonHit = isPreviewBotId(entry.id) || ent === me;
+ if (!canBalloonHit) continue;
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const pdx = b.x - cc.cx, pdy = b.y - cc.cy;
+ if (pdx * pdx + pdy * pdy <= (34 * 34)) {
+ applyBalloonBossHitToEntity(ent);
+ balloonBossPlayerBullets.splice(i, 1);
+ pHit = true;
+ }
+ }
+ if (pHit) continue;
+ const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
+ if (dx * dx + dy * dy <= bossR * bossR) {
+ balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
+ balloonBossScorePopups.push({
+ ownerId: b.ownerId,
+ x: b.x,
+ y: b.y,
+ t: 0.85,
+ tMax: 0.85,
+ });
+ const addScore = 10;
+ const addBossDmg = 2;
+ if (b.ownerId === myId) {
+ me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore);
+ me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg);
+ if (socket && myId != null) {
+ balloonBossLastMoveEmit = Date.now();
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ } else {
+ const o = others.get(b.ownerId);
+ if (o) {
+ o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore);
+ o.balloonBossBossDmg = Math.max(0, (o.balloonBossBossDmg | 0) + addBossDmg);
+ }
+ }
+ balloonBossPlayerBullets.splice(i, 1);
+ }
+ }
+
+ const tWind = performance.now() * 0.001;
+ const gustAx = Math.sin(tWind * 0.36 + 1.15) * 62 + Math.sin(tWind * 1.05) * 24;
+ const gustAy = Math.cos(tWind * 0.39 + 0.28) * 58 + Math.cos(tWind * 0.93) * 22;
+ const maxSpd = Math.min(228, balloonBossShipMaxSpeedPlay() * 1.32);
+ const dragPerSec = balloonBossShipDragPlay() * 0.4;
+ if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
+ if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
+ let nvx = me.balloonBossVelX;
+ let nvy = me.balloonBossVelY;
+ nvx += gustAx * dt;
+ nvy += gustAy * dt;
+ const drag = Math.min(1, dragPerSec * dt);
+ nvx *= (1 - drag);
+ nvy *= (1 - drag);
+ const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
+ if (curSpd > maxSpd) {
+ const s = maxSpd / curSpd;
+ nvx *= s;
+ nvy *= s;
+ }
+ me.balloonBossVelX = nvx;
+ me.balloonBossVelY = nvy;
+ if (!me.balloonBossEliminated && me.balloonBossCx != null) {
+ const ncx = me.balloonBossCx + nvx * dt;
+ const ncy = me.balloonBossCy + nvy * dt;
+ const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
+ if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX *= -0.88;
+ if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY *= -0.88;
+ me.balloonBossCx = cl.cx;
+ me.balloonBossCy = cl.cy;
+ }
+ syncBalloonBossFootFromCenter(me);
+ balloonBossPlayerElasticCollisionsPlay(me, aliveRefs, mw, mh);
+ balloonBossPlayerBossElasticPlay(me, bossC.cx, bossC.cy, bossR, mw, mh);
+ syncBalloonBossFootFromCenter(me);
+ /** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */
+ me.direction = 'down';
+ me.isWalking = Math.abs(me.balloonBossVelX) > 12 || Math.abs(me.balloonBossVelY) > 12;
+
+ /** วงลูกศรหมุนเองเรื่อย ๆ (คุมไม่ได้) — Mega Virus ยิงตามมุมลูกศร ณ ตอนกด Space */
+ aliveRefs.forEach((e) => {
+ const r = e.ref;
+ if (!r || r.balloonBossEliminated) return;
+ if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) {
+ r.balloonBossAimRad = Math.random() * Math.PI * 2;
+ }
+ r.balloonBossAimRad += 1.02 * dt;
+ });
+
+ balloonBossPlayerFireCd -= dt;
+ const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
+ if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
+ balloonBossPlayerFireCd = 0.35;
+ const delayMs = balloonBossFireDelayMsPlay();
+ const bspd = 480;
+ let vx;
+ let vy;
+ if (isMegaVirusMissionShellMapPlay()) {
+ const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad))
+ ? me.balloonBossAimRad
+ : Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx);
+ vx = Math.cos(aim) * bspd;
+ vy = Math.sin(aim) * bspd;
+ } else {
+ const dx = bossC.cx - me.balloonBossCx;
+ const dy = bossC.cy - me.balloonBossCy;
+ const L = Math.hypot(dx, dy) || 1;
+ vx = (dx / L) * bspd;
+ vy = (dy / L) * bspd;
+ }
+ balloonBossPendingShots.push({
+ releaseAt: now + delayMs,
+ sx: me.balloonBossCx, sy: me.balloonBossCy,
+ vx,
+ vy,
+ ownerId: myId,
+ });
+ }
+
+ stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
+
+ const tEmit = Date.now();
+ if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
+ balloonBossLastMoveEmit = tEmit;
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ balloonBossScore: Math.max(0, me.balloonBossScore | 0),
+ balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
+ balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
+ balloonBossEliminated: !!me.balloonBossEliminated,
+ });
+ }
+ }
+
+ function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
+ if (!mapData || !isBalloonBoss()) return;
+ const ts = tileSize;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const mw = w * ts, mh = h * ts;
+ const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
+ const maxHp = balloonBossMaxHpPlay();
+ const dmg = balloonBossTeamBossDamagePlay();
+ const hpLeft = Math.max(0, maxHp - dmg);
+ const bossR = Math.max(36, ts * 1.15);
+ const refs = buildBalloonBossParticipantRefsPlay();
+ const leaderId = (() => {
+ let best = null, bs = -1;
+ refs.forEach((e) => {
+ if (!e.ref || e.ref.balloonBossEliminated) return;
+ const sc = Math.max(0, e.ref.balloonBossScore | 0);
+ if (sc > bs) { bs = sc; best = e.id; }
+ });
+ return best;
+ })();
+ const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || ''));
+ const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null;
+
+ for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
+ const fx = balloonBossHitFx[hi];
+ const [hx, hy] = worldToScreen(fx.x, fx.y);
+ const pulse = 1 + (fx.t || 0) * 3;
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
+ ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
+ ctx.lineWidth = 3 * zDraw;
+ ctx.beginPath();
+ ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ const score10PopUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/score+10.png');
+ const score10PopRec = score10PopUrl ? ensureGauntletAssetImage(score10PopUrl) : null;
+ for (let si = 0; si < balloonBossScorePopups.length; si++) {
+ const sp = balloonBossScorePopups[si];
+ let wx = sp.x;
+ let wy = sp.y;
+ if (sp.ownerId != null) {
+ const oid = sp.ownerId;
+ const ent = (myId != null && String(oid) === String(myId)) ? me : others.get(oid);
+ if (ent && ent.balloonBossCx != null && !ent.balloonBossEliminated) {
+ const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
+ const tMax = typeof sp.tMax === 'number' && sp.tMax > 0 ? sp.tMax : 0.95;
+ const rise = ((tMax - Math.max(0, sp.t || 0)) / tMax) * 58;
+ wx = cc.cx;
+ wy = cc.cy - 46 - rise;
+ }
+ }
+ const [sx, sy] = worldToScreen(wx, wy);
+ ctx.save();
+ ctx.globalAlpha = Math.min(1, Math.max(0, sp.t || 0) * 1.12);
+ if (sp.dmgTxt) {
+ const fpx = Math.max(11, 14 * zDraw);
+ ctx.font = `bold ${fpx}px ui-sans-serif, system-ui, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.62)';
+ ctx.lineWidth = Math.max(2, 2.4 * zDraw);
+ ctx.strokeText(String(sp.dmgTxt), sx, sy);
+ ctx.fillStyle = '#f8fafc';
+ ctx.fillText(String(sp.dmgTxt), sx, sy);
+ } else if (score10PopRec && score10PopRec.ready && score10PopRec.img && score10PopRec.img.naturalWidth > 0) {
+ const iw = score10PopRec.img.naturalWidth;
+ const ih = score10PopRec.img.naturalHeight;
+ const mh = Math.max(14, 20 * zDraw);
+ const sc = Math.min(1.35, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(score10PopRec.img, sx - dw / 2, sy - dh / 2, dw, dh);
+ }
+ ctx.restore();
+ }
+
+ const bossAlive = hpLeft > 0;
+ if (bossAlive) {
+ const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
+ ctx.save();
+ ctx.translate(bsx, bsy);
+ const limR = bossR * zDraw;
+ if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) {
+ const iw = bossImgRec.img.naturalWidth;
+ const ih = bossImgRec.img.naturalHeight;
+ const diam = limR * 2.1;
+ const scale = Math.min(diam / iw, diam / ih);
+ const dw = iw * scale;
+ const dh = ih * scale;
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.beginPath();
+ for (let k = 0; k < 6; k++) {
+ const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
+ const px = Math.cos(ang) * bossR * zDraw * 1.05;
+ const py = Math.sin(ang) * bossR * zDraw * 1.05;
+ if (k === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
+ ctx.fill();
+
+ const skullR = bossR * zDraw * 0.62;
+ const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
+ grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
+ grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
+ ctx.fillStyle = grd;
+ ctx.beginPath();
+ ctx.arc(0, 0, skullR, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
+ ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
+ ctx.shadowBlur = 12 * zDraw;
+ ctx.beginPath();
+ ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ /** HUD บอส: แถบชิดเหนือหัวบอส; โลโก้ Artboard10 + [hp/max] เหนือแถบ — bbHudS ลดสเกลทั้งแถว (ขอให้เล็กลง ~ครึ่ง) */
+ const bbHudS = 0.5;
+ const fontPx = Math.max(8, 12.5 * zDraw * bbHudS);
+ ctx.font = `bold ${fontPx}px ui-monospace, monospace`;
+ ctx.textBaseline = 'middle';
+ const hpStr = '[ ' + hpLeft + ' / ' + maxHp + ' ]';
+ const hpW = ctx.measureText(hpStr).width;
+ const gapHp = 5 * zDraw * bbHudS;
+
+ const powBgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power-bg.png');
+ const powFillUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power.png');
+ const powBgRec = powBgUrl ? ensureGauntletAssetImage(powBgUrl) : null;
+ const powFillRec = powFillUrl ? ensureGauntletAssetImage(powFillUrl) : null;
+
+ /** ความสูงหลอด: ยึดความกว้างก่อน — คูณ bbHudS ให้สัดส่วนเทียบหัวบอสเล็กลง */
+ const barHMin = Math.max(9, 10.5 * zDraw) * bbHudS;
+ const barHMax = Math.max(17, 21 * zDraw) * bbHudS;
+
+ const titleImgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/Artboard%2010.png');
+ const titleImgRec = titleImgUrl ? ensureGauntletAssetImage(titleImgUrl) : null;
+ let imgW = 0;
+ let imgH = 0;
+ if (titleImgRec && titleImgRec.ready && titleImgRec.img && titleImgRec.img.naturalWidth > 0) {
+ const iw = titleImgRec.img.naturalWidth;
+ const ih = titleImgRec.img.naturalHeight;
+ let barHForTitle = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const biw = powBgRec.img.naturalWidth;
+ const bijh = powBgRec.img.naturalHeight;
+ const wEst = Math.max(limR * 2.12 * bbHudS, hpW + gapHp + iw * 0.35 * bbHudS);
+ barHForTitle = Math.min(barHMax, Math.max(barHMin, bijh * (wEst / biw)));
+ }
+ const capTitle = Math.min(17 * zDraw * bbHudS, Math.max(fontPx * 1.15, barHForTitle * 0.88));
+ const maxH = Math.max(fontPx * 1.12, 14 * zDraw * bbHudS, capTitle);
+ const sc = Math.min(1.0, maxH / ih);
+ imgW = iw * sc;
+ imgH = ih * sc;
+ }
+
+ let rowW = (imgW > 0 ? imgW + gapHp : 0) + hpW;
+ if (imgW <= 0) {
+ const legacyPre = 'MEGA VIRUS : ';
+ rowW = ctx.measureText(legacyPre).width + hpW;
+ }
+ const barPadRow = 8 * zDraw * bbHudS;
+ const barWTarget = Math.max(limR * 2.08 * bbHudS, rowW + barPadRow);
+
+ let barDw = barWTarget;
+ let barDh = barHMin;
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ const iw = powBgRec.img.naturalWidth;
+ const ih = powBgRec.img.naturalHeight;
+ const naturalH = ih * (barWTarget / iw);
+ barDh = Math.min(barHMax, Math.max(barHMin, naturalH));
+ }
+
+ const gapBarBoss = 4 * zDraw * bbHudS;
+ const gapTitleBar = 5 * zDraw * bbHudS;
+ const barBottomEdge = -limR * 1.0 - gapBarBoss;
+ const barTopY = barBottomEdge - barDh;
+ const bx = -barDw / 2;
+ const by = barTopY;
+ const ratHp = Math.max(0, Math.min(1, hpLeft / maxHp));
+
+ const titleRowH = Math.max(imgH, fontPx * 1.12);
+ const labelY = barTopY - gapTitleBar - titleRowH * 0.5;
+
+ let xLeft = -(imgW + (imgW > 0 ? gapHp : 0) + hpW) / 2;
+ if (imgW > 0) {
+ ctx.drawImage(titleImgRec.img, xLeft, labelY - imgH / 2, imgW, imgH);
+ xLeft += imgW + gapHp;
+ } else {
+ ctx.textAlign = 'left';
+ ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
+ const legacyPre = 'MEGA VIRUS : ';
+ const pw = ctx.measureText(legacyPre).width;
+ xLeft = -(pw + hpW) / 2;
+ ctx.fillText(legacyPre, xLeft, labelY);
+ xLeft += pw;
+ }
+ ctx.textAlign = 'left';
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
+ ctx.lineWidth = Math.max(0.5, 1 * zDraw * bbHudS);
+ ctx.strokeText(hpStr, xLeft, labelY);
+ ctx.fillText(hpStr, xLeft, labelY);
+
+ if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
+ ctx.drawImage(powBgRec.img, bx, by, barDw, barDh);
+ if (ratHp > 0 && powFillRec && powFillRec.ready && powFillRec.img && powFillRec.img.naturalWidth > 0) {
+ const fiw = powFillRec.img.naturalWidth;
+ const fih = powFillRec.img.naturalHeight;
+ const padIn = barDw * 0.04;
+ const innerW = barDw - padIn * 2;
+ const innerH = barDh - padIn * 1.2;
+ const fsc2 = Math.min(innerW / fiw, innerH / fih);
+ const fdw2 = fiw * fsc2;
+ const fdh2 = fih * fsc2;
+ const fx0 = bx + (barDw - fdw2) * 0.5;
+ const fy0 = by + (barDh - fdh2) * 0.5;
+ const srcW = fiw * ratHp;
+ ctx.drawImage(powFillRec.img, 0, 0, srcW, fih, fx0, fy0, fdw2 * ratHp, fdh2);
+ }
+ } else {
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
+ ctx.fillRect(bx, by, barDw, barDh);
+ ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
+ ctx.fillRect(bx, by, barDw * ratHp, barDh);
+ ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.strokeRect(bx, by, barDw, barDh);
+ }
+ ctx.restore();
+ }
+
+ /** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */
+ function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) {
+ const lx = 0;
+ const ly = dh * 0.5;
+ const c = Math.cos(tiltRad);
+ const s = Math.sin(tiltRad);
+ return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c };
+ }
+
+ /** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
+ function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
+ const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
+ const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
+ const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
+ const n = Math.max(0, Math.min(8, count | 0));
+ const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
+ const sz = 1.5;
+ const tw = 22 * sz * z;
+ const th = 27 * sz * z;
+ const stackAnchor = ringRpx + 10 * z;
+ const tieX = sx;
+ const tieY = sy - ringRpx * 0.98;
+ const tiltStep = 0.5;
+ const spreadX = 5.4 * z;
+ const baseY = sy - stackAnchor - 1.5 * z;
+ const items = [];
+ for (let b = 0; b < n; b++) {
+ const rel = b - (n - 1) / 2;
+ const tilt = rel * tiltStep;
+ const bx = sx + rel * spreadX;
+ const by = baseY + Math.abs(rel) * 2.1 * z;
+ const bImg = bRec && bRec.img;
+ let dw = 0;
+ let dh = 0;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ dw = iw * sc;
+ dh = ih * sc;
+ } else {
+ const ry = 12 * sz * z;
+ dw = 10 * sz * z * 2;
+ dh = ry * 2;
+ }
+ const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt);
+ items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y });
+ }
+ if (n > 0) {
+ ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)';
+ ctx.lineWidth = Math.max(0.65, 0.78 * z);
+ ctx.lineCap = 'butt';
+ ctx.lineJoin = 'bevel';
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ ctx.beginPath();
+ ctx.moveTo(tieX, tieY);
+ ctx.lineTo(it.knotX, it.knotY);
+ ctx.stroke();
+ }
+ }
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ const bImg = it.bImg;
+ if (bImg && bImg.complete && bImg.naturalWidth > 0) {
+ const iw = bImg.naturalWidth;
+ const ih = bImg.naturalHeight;
+ const sc = Math.min(tw / iw, th / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ } else {
+ ctx.save();
+ ctx.translate(it.bx, it.by);
+ ctx.rotate(it.tilt);
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+ }
+
+ refs.forEach((entry, idx) => {
+ const ent = entry.ref;
+ if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
+ if (ent.balloonBossEliminated) return;
+ const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
+ const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
+ const z = zDraw;
+ const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6)
+ ? ent.balloonBossSkinSlot
+ : ((idx % 6) + 1);
+ const ringR = 25 * z;
+ /** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
+ const bubbleCy = sy - ringR * 0.42;
+ /** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
+ const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad))
+ ? ent.balloonBossAimRad
+ : ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2));
+ /** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
+ const faceDir = 'down';
+ const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
+ const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim();
+ const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
+ /** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */
+ const ringRec = fbRing
+ ? resolveBalloonBossBalloonSpriteRec('', fbRing)
+ : resolveBalloonBossBalloonSpriteRec(perRing, '');
+ ctx.save();
+ const ringImg = ringRec && ringRec.img;
+ const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0);
+ if (ringPainted) {
+ const iw = ringImg.naturalWidth;
+ const ih = ringImg.naturalHeight;
+ const maxBox = 2 * ringR * 1.14;
+ const sc = Math.min(maxBox / iw, maxBox / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ /** หางฟอง (Artboard +Y) หมุนตาม angRing — tail ชี้ทิศยิง */
+ ctx.save();
+ ctx.translate(sx, bubbleCy);
+ ctx.rotate(angRing - Math.PI / 2);
+ ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh);
+ ctx.restore();
+ }
+ if (!ringPainted) {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)';
+ ctx.lineWidth = Math.max(1.2, 1.6 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ } else {
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
+ ctx.lineWidth = Math.max(1, 1.1 * z);
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR);
+ const nm = String(ent.nickname || '').slice(0, 12);
+ ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
+ ctx.lineWidth = 3;
+ let label = nm;
+ if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
+ const labelY = bubbleCy + ringR + 8 * z;
+ ctx.strokeText(label, sx, labelY);
+ ctx.fillText(label, sx, labelY);
+ const charImg = resolvePlayDrawCharSource(
+ ent.characterId, faceDir, timeMs, !!ent.isWalking,
+ ent.playTint || resolvePlayTintForPeer(entry.id),
+ myId != null && entry.id != null && String(entry.id) === String(myId),
+ );
+ const size = 26 * z;
+ if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
+ const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
+ const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
+ const sc = Math.min(size / iw, size / ih, 1);
+ const dw = iw * sc, dh = ih * sc;
+ const charTop = bubbleCy - dh * 0.48;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh);
+ } else {
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)';
+ ctx.lineWidth = Math.max(1, 1.2 * z);
+ ctx.stroke();
+ }
+ ctx.restore();
+ });
+
+ const bulletCodeUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/bullet-code.png');
+ const bulletCodeRec = bulletCodeUrl ? ensureGauntletAssetImage(bulletCodeUrl) : null;
+ for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
+ const b = balloonBossPlayerBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.save();
+ ctx.translate(bx, by);
+ /** bullet-code.png หัวลูกศรชี้ขวา (+x) — หมุนตามทิศความเร็วโดยไม่บวก π/2 (ก่อนหน้านี้ดูเหมือนยิงเฉียง) */
+ ctx.rotate(Math.atan2(b.vy, b.vx));
+ if (bulletCodeRec && bulletCodeRec.ready && bulletCodeRec.img && bulletCodeRec.img.naturalWidth > 0) {
+ const iw = bulletCodeRec.img.naturalWidth;
+ const ih = bulletCodeRec.img.naturalHeight;
+ const mh = Math.max(10, 15 * zDraw);
+ const sc = Math.min(1.4, mh / ih);
+ const dw = iw * sc;
+ const dh = ih * sc;
+ ctx.drawImage(bulletCodeRec.img, -dw / 2, -dh / 2, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
+ ctx.beginPath();
+ ctx.moveTo(0, -8 * zDraw);
+ ctx.lineTo(-5 * zDraw, 6 * zDraw);
+ ctx.lineTo(5 * zDraw, 6 * zDraw);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
+ ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
+ ctx.textAlign = 'center';
+ ctx.fillText('01001', 0, 12 * zDraw);
+ }
+ ctx.restore();
+ }
+ for (let i = 0; i < balloonBossBossBullets.length; i++) {
+ const b = balloonBossBossBullets[i];
+ const [bx, by] = worldToScreen(b.x, b.y);
+ ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
+ ctx.beginPath();
+ ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
+ ctx.stroke();
+ }
+ }
+
+ function jumpSurviveCollectPlatformCells(md) {
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ const out = [];
+ if (!pa) return out;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ if (pa[y] && pa[y][x] === 1) out.push({ x, y });
+ }
+ }
+ return out;
+ }
+
+ /** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกล่างจะวนมาโผล่บน */
+ function jumpSurvivePlatformPeriodPx(md) {
+ return (md.height || 15) * tileSize;
+ }
+
+ /**
+ * ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
+ */
+ function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
+ const ts = tileSize;
+ const h = md.height || 15;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const rawTop = ty * ts + scrollPx;
+ const hintY = (jumpSurviveCamCenterY > 1)
+ ? jumpSurviveCamCenterY
+ : h * ts * 0.72;
+ const k = Math.round((hintY - rawTop - ts * 0.35) / period);
+ const platTop = rawTop + k * period;
+ const feetY = platTop;
+ const pxc = (tx + 0.5) * ts;
+ return {
+ x: pxc / ts - cw * 0.5,
+ y: feetY / ts - ch,
+ };
+ }
+
+ function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
+ return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
+ }
+
+ function jumpSurviveRandomStandGridPos(md) {
+ const cells = jumpSurviveCollectPlatformCells(md);
+ const sc = jumpSurvivePlatformScrollPx;
+ if (cells.length) {
+ const c = cells[Math.floor(Math.random() * cells.length)];
+ return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
+ }
+ const sp = md.spawn || { x: 1, y: 1 };
+ return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
+ }
+
+ function jumpSurviveTileHasPlatformCellPlay(md, tx, ty) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ const h = md.height || (pa && pa.length) || 15;
+ const w = md.width || 20;
+ if (!pa || ty < 0 || ty >= h) return false;
+ const row = pa[ty];
+ if (!row || tx < 0 || tx >= w) return false;
+ return row[tx] === 1;
+ }
+
+ /** หาแพลตฟอร์มในคอลัมน์ tx ใกล้ ty — กันจุดเกิดช่องว่างแล้วลอย */
+ function jumpSurviveFindPlatformColumnPlay(md, tx, tyHint) {
+ const w = md.width || 20, h = md.height || 15;
+ const tcx = Math.max(0, Math.min(w - 1, Math.floor(tx)));
+ let ty = Math.max(0, Math.min(h - 1, Math.floor(tyHint)));
+ if (jumpSurviveTileHasPlatformCellPlay(md, tcx, ty)) return { x: tcx, y: ty };
+ for (let d = 1; d < h; d++) {
+ const yb = ty + d;
+ if (yb < h && jumpSurviveTileHasPlatformCellPlay(md, tcx, yb)) return { x: tcx, y: yb };
+ const ya = ty - d;
+ if (ya >= 0 && jumpSurviveTileHasPlatformCellPlay(md, tcx, ya)) return { x: tcx, y: ya };
+ }
+ return { x: tcx, y: ty };
+ }
+
+ /** จุดเกิดตามลำดับ join / P1–P6 → world ยืนบนแพลตฟอร์ม (ไม่ทับสุ่ม pool) */
+ function jumpSurviveSpawnWorldFromJoinOrderPlay(md, joinOrder) {
+ const sp = pickSpawnForJoinPlay(md, joinOrder | 0);
+ let tx = Math.floor(Number(sp.x));
+ let ty = Math.floor(Number(sp.y));
+ if (!Number.isFinite(tx) || !Number.isFinite(ty)) {
+ const fb = md.spawn || { x: 1, y: 1 };
+ tx = Math.floor(Number(fb.x)) || 1;
+ ty = Math.floor(Number(fb.y)) || 1;
+ }
+ const cell = jumpSurviveFindPlatformColumnPlay(md, tx, ty);
+ const sc = jumpSurvivePlatformScrollPx;
+ const p = jumpSurviveGridPosStandingOnPlatform(md, cell.x, cell.y, sc);
+ return {
+ x: p.x + (Math.random() - 0.5) * 0.05,
+ y: p.y + (Math.random() - 0.5) * 0.05,
+ };
+ }
+
+ /** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
+ /** Quiz Battle พรีวิว: บอท spawn ตามโหมดแมป — ผู้เล่นจริงใช้ตำแหน่งจาก server แล้ว snap เท่าที่จำเป็น */
+ function applyQuizBattlePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'quiz_battle' || !quizBattlePathModeActive(mapData)) return;
+
+ function stampQuizEnt(ent, joinOrd) {
+ if (!ent) return;
+ const jo = Number.isFinite(Number(joinOrd)) ? Math.max(0, Math.floor(Number(joinOrd))) : 0;
+ const pos = quizBattleSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ ent.tx = pos.x;
+ ent.ty = pos.y;
+ }
+
+ if (!onlyBots) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = sn.x;
+ me.y = sn.y;
+ me.tx = sn.x;
+ me.ty = sn.y;
+ }
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ if (!onlyBots) {
+ realIds.forEach((rid) => {
+ const o = others.get(rid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : realIds.indexOf(rid);
+ stampQuizEnt(o, jo);
+ });
+ }
+ botIds.forEach((bid, bi) => {
+ const o = others.get(bid);
+ if (!o) return;
+ const jo = Number.isFinite(Number(o.spawnJoinOrder)) ? o.spawnJoinOrder : countPlayHumans() + bi;
+ stampQuizEnt(o, jo);
+ });
+ }
+
+ function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
+ if (!mapData || mapData.gameType !== 'jump_survive') return;
+
+ function stampJumpBotState(o) {
+ if (!o) return;
+ o.jumpSurviveVy = 0;
+ o.jumpSurviveOnGround = true;
+ o.jumpSurviveEliminated = false;
+ o.tx = o.x;
+ o.ty = o.y;
+ }
+
+ if (isJumpSurviveMissionUiMapPlay()) {
+ function snapEnt(ent) {
+ if (!ent) return;
+ const ordRaw = Number(ent.spawnJoinOrder);
+ const jo = Number.isFinite(ordRaw) ? Math.max(0, Math.floor(ordRaw)) : 0;
+ const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, jo);
+ ent.x = pos.x;
+ ent.y = pos.y;
+ stampJumpBotState(ent);
+ }
+ if (!onlyBots) {
+ snapEnt(me);
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ realIds.forEach((rid) => snapEnt(others.get(rid)));
+ }
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+ botIds.forEach((bid) => snapEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ return;
+ }
+
+ const cells = jumpSurviveCollectPlatformCells(mapData);
+ cells.sort((a, b) => b.y - a.y || a.x - b.x);
+ const sc = jumpSurvivePlatformScrollPx;
+ const pool = cells.length
+ ? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
+ : [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
+ const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
+ const botIds = [...others.keys()].filter(isPreviewBotId).sort();
+
+ if (onlyBots) {
+ let idx = 1 + realIds.length;
+ botIds.forEach((bid) => {
+ const o = others.get(bid);
+ if (!o || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ o.x = p.x + (Math.random() - 0.5) * 0.08;
+ o.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(o);
+ });
+ return;
+ }
+
+ let idx = 0;
+ function assignEnt(ent) {
+ if (!ent || !pool.length) return;
+ const p = pool[idx % pool.length];
+ idx++;
+ ent.x = p.x + (Math.random() - 0.5) * 0.08;
+ ent.y = p.y + (Math.random() - 0.5) * 0.08;
+ stampJumpBotState(ent);
+ }
+ assignEnt(me);
+ realIds.forEach((rid) => assignEnt(others.get(rid)));
+ botIds.forEach((bid) => assignEnt(others.get(bid)));
+ jumpSurviveInitRuntime();
+ }
+
+ /**
+ * ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
+ * ตายจาก: hazard ที่วาง, บีบระหว่าง solid+object wall, หรือตกออกนอกขอบโลกแมป (ไม่ใช้ขอบกล้อง)
+ * @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
+ */
+ function jumpSurvivePhysicsIntegrate(md, opts) {
+ const {
+ dt,
+ cw, ch,
+ pxc0, feetY0, vy0, wasOnGround,
+ jumpQueued,
+ moveLeft, moveRight,
+ } = opts;
+ const w = md.width || 20, h = md.height || 15;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const bh = ts * 0.9;
+ let pxc = pxc0;
+ let feetY = feetY0;
+ let vy = vy0;
+ let onGround = wasOnGround;
+
+ const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
+ const g = gFull * dt;
+ let jumpV0;
+ if (Number(md.jumpSurviveJumpImpulse) > 0) {
+ jumpV0 = -Number(md.jumpSurviveJumpImpulse);
+ } else {
+ const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
+ ? playJumpSurviveJumpHeightMult
+ : 1.5;
+ const charHpixels = ch * ts;
+ const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
+ jumpV0 = -Math.min(impulse, ts * 36);
+ }
+ let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
+ if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35;
+
+ if (jumpQueued && onGround) {
+ vy = jumpV0;
+ onGround = false;
+ }
+ vy += g;
+
+ let nx = pxc;
+ if (moveLeft) nx -= moveX;
+ if (moveRight) nx += moveX;
+ nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
+
+ const headY0 = feetY - bh;
+ const tryL = nx - bw;
+ const tryR = nx + bw;
+ if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) {
+ pxc = nx;
+ }
+
+ feetY += vy * dt;
+ let headTop = feetY - bh;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ if (vy > 0) {
+ const step = ts * 0.035;
+ const penetrationEst = Math.abs(vy) * dt + ts * 0.55;
+ const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6));
+ for (let s = 0; s < maxSteps; s++) {
+ feetY -= step;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ onGround = true;
+ break;
+ }
+ }
+ } else if (vy < 0) {
+ const stepUp = ts * 0.035;
+ const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55;
+ const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6));
+ for (let s = 0; s < maxStepsUp; s++) {
+ feetY += stepUp;
+ headTop = feetY - bh;
+ if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ vy = 0;
+ break;
+ }
+ }
+ }
+ } else {
+ onGround = false;
+ }
+
+ headTop = feetY - bh;
+ const headBandBottom = headTop + bh * 0.42;
+ if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
+ jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ headTop = feetY - bh;
+ /* ไม่ใช้ขอบกล้อง (cam topKill/botKill) — ตายเฉพาะ hazard ที่วาง + ตกออกนอกโลกแมปจริง (ไม่ตายในอากาศระหว่างแพลตฟอร์ม) */
+ const mapHpx = h * ts;
+ if (feetY > mapHpx + ts * 8 || headTop < -ts * 32) {
+ return { pxc, feetY, vy, onGround, died: true };
+ }
+
+ return { pxc, feetY, vy, onGround, died: false };
+ }
+
+ function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ o.x = pxc / tileSize - cw * 0.5;
+ o.y = feetYpx / tileSize - ch;
+ }
+
+ function jumpSurviveInitRuntime() {
+ jumpSurviveLastTickMs = performance.now();
+ jumpSurviveSessionStartMs = performance.now();
+ jumpSurviveVy = 0;
+ jumpSurviveJumpQueued = false;
+ jumpSurviveOnGround = false;
+ jumpSurvivePlatformScrollPx = 0;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ts = tileSize;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ const w = mapData.width || 20, h = mapData.height || 15;
+ jumpSurviveCamCenterX = (w * ts) * 0.5;
+ jumpSurviveCamCenterY = (h * ts) * 0.5;
+ } else {
+ jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
+ jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
+ }
+ }
+
+ function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w) return true;
+ if (ty < 0) continue;
+ /* แพลตฟอร์มวนแนวตั้ง (period = h*ts) — worldY เกิน h*ts เป็นปกติ ห้ามถือ ty>=h เป็น “ซีลพื้นแมป” ไม่งั้น solid+กำแพงข้าง = ตายคาแอร์ */
+ if (ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ for (let ty = 0; ty < h; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (!pa[ty] || pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ /* ceil/floor กับช่วงสั้น ๆ อาจได้ kMin > kMax → ไม่ลอง k เลย แล้วตกทะลุแพลตฟอร์ม */
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** แถวแพลตฟอร์มบนสุด/ล่างสุดของแมป (มีช่องอย่างน้อยหนึ่งช่อง) — ใช้เป็นเพดาน/พื้นตาย */
+ function jumpSurvivePlatformEdgeKillRows(md) {
+ const pa = md && md.jumpSurvivePlatformArea;
+ if (!pa) return null;
+ const h = Math.min(pa.length, md.height || pa.length || 15);
+ const wM = md.width || 20;
+ let minTy = h;
+ let maxTy = -1;
+ for (let ty = 0; ty < h; ty++) {
+ const row = pa[ty];
+ if (!row) continue;
+ const wlim = Math.min(row.length || 0, wM);
+ let rowHas = false;
+ for (let tx = 0; tx < wlim; tx++) {
+ if (row[tx] === 1) {
+ rowHas = true;
+ break;
+ }
+ }
+ if (!rowHas) continue;
+ if (ty < minTy) minTy = ty;
+ if (ty > maxTy) maxTy = ty;
+ }
+ if (minTy > maxTy) return null;
+ if (minTy === maxTy) return null;
+ return { minTy, maxTy };
+ }
+
+ /** ชนแพลตฟอร์มแถวเพดานเท่านั้น (รวมเลื่อนแบบคาบ) = ตายทันที — ไม่ฆ่าที่แถวพื้นล่าง */
+ function jumpSurviveOverlapsEdgeKillPlatforms(md, left, top, right, bottom, scrollPx) {
+ const edges = jumpSurvivePlatformEdgeKillRows(md);
+ if (!edges) return false;
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const pa = md.jumpSurvivePlatformArea;
+ if (!pa) return false;
+ const period = jumpSurvivePlatformPeriodPx(md);
+ if (period < ts) return false;
+ const x0 = Math.max(0, Math.floor(left / ts));
+ const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
+ const vm = ts * 2;
+ const overlapsRowTy = function (ty) {
+ if (ty < 0 || ty >= h || !pa[ty]) return false;
+ for (let tx = x0; tx <= x1; tx++) {
+ if (pa[ty][tx] !== 1) continue;
+ const rawTop = ty * ts + scrollPx;
+ let kMin = Math.ceil((top - rawTop - ts - vm) / period);
+ let kMax = Math.floor((bottom - rawTop + vm) / period);
+ if (kMin > kMax) {
+ const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
+ kMin = kGuess;
+ kMax = kGuess;
+ }
+ const zSlack = ts * 0.08;
+ for (let k = kMin; k <= kMax; k++) {
+ const platTop = rawTop + k * period;
+ const platBot = platTop + ts;
+ const tileLeft = tx * ts;
+ const tileRight = (tx + 1) * ts;
+ if (platBot > top - zSlack && platTop < bottom + zSlack && right > tileLeft && left < tileRight) return true;
+ }
+ }
+ return false;
+ };
+ if (overlapsRowTy(edges.minTy)) return true;
+ return false;
+ }
+
+ function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /**
+ * ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย
+ * (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน)
+ */
+ function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) {
+ if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
+ const ts = tileSize;
+ const footSlop = ts * 0.16;
+ const platformBottom = bottom - footSlop;
+ if (platformBottom <= top + ts * 0.05) return false;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx);
+ }
+
+ /** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
+ function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ const x0 = Math.floor(left / ts);
+ const x1 = Math.floor((right - 1e-6) / ts);
+ const y0 = Math.floor(top / ts);
+ const y1 = Math.floor((bottom - 1e-6) / ts);
+ for (let ty = y0; ty <= y1; ty++) {
+ for (let tx = x0; tx <= x1; tx++) {
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
+ const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
+ if (ob === 1) return true;
+ }
+ }
+ return false;
+ }
+
+ function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
+ jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
+ }
+
+ function jumpSurviveCameraTargetPx(md) {
+ const ts = tileSize;
+ const w = md.width || 20, h = md.height || 15;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 };
+ }
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = (e) => (e.x + cw * 0.5) * ts;
+ const cy = (e) => (e.y + ch * 0.5) * ts;
+ if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
+ const aliveHumanIds = [...others.keys()].filter((id) => {
+ if (isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const aliveBotIds = [...others.keys()].filter((id) => {
+ if (!isPreviewBotId(id)) return false;
+ const o = others.get(id);
+ return o && !o.jumpSurviveEliminated;
+ });
+ const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
+ if (!useIds.length) return { px: cx(me), py: cy(me) };
+ let sx = 0, sy = 0;
+ useIds.forEach((id) => {
+ const o = others.get(id);
+ if (!o) return;
+ sx += cx(o);
+ sy += cy(o);
+ });
+ const n = useIds.length;
+ return { px: sx / n, py: sy / n };
+ }
+
+ /** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */
+ function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) {
+ if (!wishDir) return true;
+ const ts = tileSize;
+ const bw = ts * 0.34;
+ const probeX = pxc + wishDir * (bw + ts * 0.82);
+ const pad = ts * 0.24;
+ const left = probeX - pad;
+ const right = probeX + pad;
+ const top = feetY - ts * 0.45;
+ const bottom = feetY + ts * 8;
+ return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx);
+ }
+
+ function stepJumpSurvivePreviewBots(dt, halfViewH) {
+ if (!playBotsEnabled() || !mapData || !isJumpSurvive()) return;
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const ts = tileSize;
+ const wpx = (md.width || 20) * ts;
+ const centerPx = wpx * 0.5;
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ if (o.jumpSurviveEliminated) return;
+ if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
+ if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
+ if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
+ if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
+ if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
+
+ let pxc = (o.x + cw * 0.5) * ts;
+ let feetY = (o.y + ch) * ts;
+
+ const camCy = jumpSurviveCamCenterY;
+ const mission = isJumpSurviveMissionUiMapPlay();
+ const lowDanger = feetY > camCy + halfViewH - ts * 8.5;
+
+ if (Date.now() >= o.jumpSurviveWishNext) {
+ const wMin = mission && lowDanger ? 180 : 380;
+ const wSpan = mission && lowDanger ? 480 : 700;
+ o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan);
+ const tier = o.botTier || 'avg';
+ if (mission && lowDanger && Math.random() < 0.55) {
+ o.jumpSurviveWishX = 0;
+ } else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
+ else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
+ else if (tier === 'sharp') {
+ const roam = mission ? 0.42 : 0.55;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ } else {
+ const roam = mission ? 0.28 : 0.4;
+ o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
+ }
+ }
+
+ let moveLeft = o.jumpSurviveWishX < 0;
+ let moveRight = o.jumpSurviveWishX > 0;
+ let hazardJump = false;
+ if (o.jumpSurviveOnGround) {
+ const sc = jumpSurvivePlatformScrollPx;
+ if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0;
+ hazardJump = true;
+ } else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) {
+ o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0;
+ hazardJump = true;
+ }
+ moveLeft = o.jumpSurviveWishX < 0;
+ moveRight = o.jumpSurviveWishX > 0;
+ }
+
+ let jumpQ = false;
+ if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now());
+ if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
+ o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
+ const tier = o.botTier || 'avg';
+ let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
+ if (mission) jp += 0.12;
+ if (lowDanger) jp += 0.22;
+ jp = Math.min(0.9, jp);
+ if (hazardJump || Math.random() < jp) jumpQ = true;
+ }
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: o.jumpSurviveVy,
+ wasOnGround: o.jumpSurviveOnGround,
+ jumpQueued: jumpQ,
+ moveLeft,
+ moveRight,
+ });
+
+ if (r.died) {
+ o.jumpSurviveEliminated = true;
+ o.jumpSurviveVy = 0;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ o.botIsWalking = false;
+ return;
+ }
+ o.jumpSurviveVy = r.vy;
+ o.jumpSurviveOnGround = r.onGround;
+ jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
+ o.tx = o.x;
+ o.ty = o.y;
+ if (moveRight) o.direction = 'right';
+ else if (moveLeft) o.direction = 'left';
+ o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
+ });
+ }
+
+ function jumpSurviveTickFrame() {
+ if (!mapData || !isJumpSurvive()) return;
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
+ const remEnd = jumpSurviveRemainingSecMission();
+ if (remEnd !== null && remEnd <= 0) {
+ endJumpSurviveMissionRound('time_up');
+ return;
+ }
+ }
+ const md = mapData;
+ const ts = tileSize;
+ const now = performance.now();
+ const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
+ jumpSurviveLastTickMs = now;
+
+ let zJumpSurviveWorld = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul;
+ const halfViewH = canvas.height / (2 * zJumpSurviveWorld);
+ const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
+ jumpSurvivePlatformScrollPx += rise;
+
+ const camTgt = jumpSurviveCameraTargetPx(md);
+ const ck = Math.min(1, dt * 7);
+ jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
+ jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
+
+ if (jumpSurviveEliminated) {
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+ jumpSurviveMissionMaybeEarlyFinish();
+ return;
+ }
+
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let pxc = (me.x + cw * 0.5) * ts;
+ let feetY = (me.y + ch) * ts;
+
+ const jq = jumpSurviveJumpQueued;
+ jumpSurviveJumpQueued = false;
+
+ /* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */
+ const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0;
+ const moveLeftIn = !!(keys['ArrowLeft'] || keys['KeyA']) || joyX < -0.001;
+ const moveRightIn = !!(keys['ArrowRight'] || keys['KeyD']) || joyX > 0.001;
+
+ const r = jumpSurvivePhysicsIntegrate(md, {
+ dt,
+ cw,
+ ch,
+ pxc0: pxc,
+ feetY0: feetY,
+ vy0: jumpSurviveVy,
+ wasOnGround: jumpSurviveOnGround,
+ jumpQueued: jq,
+ moveLeft: moveLeftIn,
+ moveRight: moveRightIn,
+ });
+
+ if (r.died) {
+ jumpSurviveEliminated = true;
+ jumpSurviveVy = 0;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
+ } else {
+ jumpSurviveVy = r.vy;
+ jumpSurviveOnGround = r.onGround;
+ jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
+ }
+
+ stepJumpSurvivePreviewBots(dt, halfViewH);
+
+ if (!jumpSurviveEliminated) {
+ me.isWalking = Math.abs(jumpSurviveVy) > 40 || moveLeftIn || moveRightIn;
+ if (moveRightIn) me.direction = 'right';
+ else if (moveLeftIn) me.direction = 'left';
+
+ if (Date.now() - jumpSurviveLastEmitT > 80) {
+ jumpSurviveLastEmitT = Date.now();
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ }
+ }
+
+ jumpSurviveMissionMaybeEarlyFinish();
+ }
+
+ function gridImageLibIdleUrlPlay(entry) {
+ if (entry == null) return '';
+ if (typeof entry === 'string') return entry;
+ if (typeof entry === 'object' && entry.idle) return String(entry.idle);
+ return '';
+ }
+ function gridImageLibHeldUrlPlay(entry) {
+ if (entry == null || typeof entry === 'string') return '';
+ if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
+ return '';
+ }
+
+ function normalizeGridImageCellsOnMap(md) {
+ if (!md) return;
+ const w = md.width || 20, h = md.height || 15;
+ if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
+ md.gridImageLibrary = md.gridImageLibrary
+ .map((raw) => {
+ if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null };
+ if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) {
+ let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null;
+ if (held && held === raw.idle) held = null;
+ return { idle: raw.idle, held };
+ }
+ return null;
+ })
+ .filter(Boolean);
+ const libLen = md.gridImageLibrary.length;
+ let placements = [];
+ if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
+ placements = md.gridImageSprites.map((raw) => {
+ const i = Math.max(0, Math.floor(Number(raw.i)));
+ const x = Math.floor(Number(raw.x));
+ const y = Math.floor(Number(raw.y));
+ const ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
+ const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
+ const rawB = raw.bindCarryOption;
+ const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ const base = { i, x, y, w: ww, h: hh };
+ if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn;
+ return base;
+ }).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
+ Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
+ s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ placements = placements.map((s) => {
+ let x = s.x, y = s.y, ww = s.w, hh = s.h;
+ if (x < 0) { ww += x; x = 0; }
+ if (y < 0) { hh += y; y = 0; }
+ if (x + ww > w) ww = w - x;
+ if (y + hh > h) hh = h - y;
+ const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
+ if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption;
+ return out;
+ }).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
+ } else {
+ const cells = md.gridImageCells;
+ if (Array.isArray(cells) && cells.length === h && libLen > 0) {
+ for (let yy = 0; yy < h; yy++) {
+ const row = cells[yy];
+ if (!row) continue;
+ for (let xx = 0; xx < w; xx++) {
+ if (row[xx] == null) continue;
+ const iv = parseInt(row[xx], 10);
+ if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
+ placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
+ }
+ }
+ }
+ }
+ md.gridImagePlacements = placements;
+ const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
+ placements.forEach((sp) => {
+ for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
+ for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
+ if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
+ }
+ }
+ });
+ md.gridImageCells = outCells;
+ }
+
+ function loadMapGridImages() {
+ mapGridImageImgs = [];
+ mapGridImageHeldImgs = [];
+ if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
+ mapData.gridImageLibrary.forEach((entry, i) => {
+ mapGridImageImgs[i] = null;
+ mapGridImageHeldImgs[i] = null;
+ const idle = gridImageLibIdleUrlPlay(entry);
+ if (!idle) return;
+ const im = new Image();
+ im.onload = () => { try { draw(); } catch (e) {} };
+ im.onerror = () => {};
+ im.src = idle;
+ mapGridImageImgs[i] = im;
+ const held = gridImageLibHeldUrlPlay(entry);
+ if (held && held !== idle) {
+ const hm = new Image();
+ hm.onload = () => { try { draw(); } catch (e) {} };
+ hm.onerror = () => {};
+ hm.src = held;
+ mapGridImageHeldImgs[i] = hm;
+ }
+ });
+ }
+
+ /** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */
+ function dominantQuizCarryOptionInSpriteRect(md, sp) {
+ if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null;
+ const g = md.quizCarryOptionArea;
+ const mw = md.width || 20, mh = md.height || 15;
+ const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0;
+ const counts = new Map();
+ function addOverlapRect(x0, y0, ww, hh) {
+ for (let yy = y0; yy < y0 + hh; yy++) {
+ if (yy < 0 || yy >= mh) continue;
+ const row = g[yy];
+ if (!row) continue;
+ for (let xx = x0; xx < x0 + ww; xx++) {
+ if (xx < 0 || xx >= mw) continue;
+ const v = row[xx];
+ const n = typeof v === 'number' ? v : parseInt(String(v), 10);
+ if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue;
+ const idx = n - 1;
+ counts.set(idx, (counts.get(idx) || 0) + 1);
+ }
+ }
+ }
+ addOverlapRect(bx, by, bw, bh);
+ if (!counts.size) {
+ const pad = 1;
+ const x0 = Math.max(0, bx - pad);
+ const y0 = Math.max(0, by - pad);
+ const x1 = Math.min(mw, bx + bw + pad);
+ const y1 = Math.min(mh, by + bh + pad);
+ addOverlapRect(x0, y0, x1 - x0, y1 - y0);
+ }
+ if (!counts.size) return null;
+ let best = null;
+ let bestN = -1;
+ counts.forEach((n, k) => {
+ if (best == null || n > bestN || (n === bestN && k < best)) {
+ bestN = n;
+ best = k;
+ }
+ });
+ return best;
+ }
+
+ /** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */
+ function quizCarryOptionIndexForGridSprite(md, sp) {
+ if (!sp) return null;
+ const rawB = sp.bindCarryOption;
+ if (rawB != null && rawB !== '') {
+ const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
+ if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1;
+ }
+ return dominantQuizCarryOptionInSpriteRect(md, sp);
+ }
+
+ /** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */
+ function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) return gh;
+ }
+ return null;
+ }
+
+ /** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */
+ function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) {
+ if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
+ const pl = mapData.gridImagePlacements;
+ if (!Array.isArray(pl) || !pl.length) return null;
+ for (let gi = 0; gi < pl.length; gi++) {
+ const sp = pl[gi];
+ const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ if (opt !== choiceIdx) continue;
+ const gid = mapGridImageImgs[sp.i];
+ if (gid && gid.complete && gid.naturalWidth) return gid;
+ }
+ return null;
+ }
+
+ function applyMapAndStart(gameMapData, res) {
+ const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
+ const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
+ mapData = gameMapData;
+ playEmbedUserZoomMul = 1;
+ if (mapData.gameType !== 'quiz_carry') {
+ quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
+ }
+ {
+ const qMap = (playMapIdFromQuery || '').trim();
+ const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : '';
+ const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : '';
+ if (qMap) playSessionMapId = qMap;
+ else if (dataId) playSessionMapId = dataId;
+ else if (resMap) playSessionMapId = resMap;
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (res && res.hostId != null) playHostId = res.hostId;
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ if (prevWasSpaceShooter) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ spaceShooterMissionPhase = null;
+ spaceShooterAsteroidExplosions = [];
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccLeave = document.getElementById('gauntlet-crown-countdown');
+ if (gccLeave) gccLeave.classList.add('is-hidden');
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ if (prevWasBalloonBoss) {
+ const gend = document.getElementById('gauntlet-ended-overlay');
+ if (gend) gend.classList.add('is-hidden');
+ }
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ if (!mapData.interactive) mapData.interactive = [];
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ }
+ if (mapData.gameType === 'quiz_carry') {
+ if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
+ if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
+ if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = [];
+ if (!mapData.quizQuestions) mapData.quizQuestions = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ normalizeQuizCarryLayersInPlay(mapData);
+ applyQuizCarryWalkSpeedFromSettingsObj(quizCarryJoinSettingsSnap || {});
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ }
+ tileSize = mapData.tileSize || 32;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ const plist = Array.isArray(res.peers) ? res.peers : [];
+ const myPeer = plist.find(p => p.id === myId);
+ const myPos = peerXYFromJoin(myPeer, mapData.spawn);
+ me.x = myPos.x;
+ me.y = myPos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
+ me.x = hubSnap.x;
+ me.y = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = pathSnap.x;
+ me.y = pathSnap.y;
+ }
+ me.direction = myPeer?.direction ?? 'down';
+ if (myPeer && myPeer.nickname) {
+ me.nickname = String(myPeer.nickname).trim() || me.nickname;
+ nick = me.nickname;
+ }
+ me.characterId = myPeer?.characterId || null;
+ meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ {
+ const sc0 = Number(myPeer && myPeer.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
+ me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated);
+ }
+ {
+ const sh0 = Number(myPeer && myPeer.spaceShooterScore);
+ me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
+ }
+ {
+ const bSt = balloonBossBalloonsStartPlay();
+ const bs0 = Number(myPeer && myPeer.balloonBossScore);
+ me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
+ const bd0 = Number(myPeer && myPeer.balloonBossBossDmg);
+ me.balloonBossBossDmg = Number.isFinite(bd0) ? Math.max(0, bd0) : 0;
+ const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
+ me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
+ me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
+ }
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes;
+ const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId);
+ me.playTint = playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin);
+ if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) {
+ try {
+ const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex);
+ const si = parsePlayLobbySkinIndex(myPeerJoin.lobbySkinToneIndex) || 1;
+ if (ti) {
+ localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({
+ themeRgb: PLAY_LOBBY_THEME_HEX[ti - 1],
+ skinRgb: PLAY_LOBBY_SKIN_HEX[si - 1],
+ }));
+ }
+ } catch (eTintStore) { /* ignore */ }
+ }
+ // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม
+ try {
+ preloadPlayCharacterLayers(me.characterId || getPlayCharacterId());
+ (Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); });
+ preloadPlayOccupantsTints();
+ preloadPlayLobbyIdleAvatarsForOccupants();
+ } catch (e) { /* ignore */ }
+ {
+ const jo = Number(myPeer && myPeer.spawnJoinOrder);
+ me.spawnJoinOrder = Number.isFinite(jo) ? Math.max(0, Math.floor(jo)) : Math.max(0, plist.findIndex((q) => q && q.id === myId));
+ }
+ others.clear();
+ plist.forEach(p => {
+ if (p.id !== myId) {
+ const pos = peerXYFromJoin(p, mapData.spawn);
+ let px = pos.x, py = pos.y;
+ if (isQuizCarry()) {
+ const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
+ px = hubSnap.x;
+ py = hubSnap.y;
+ }
+ if (isQuizBattle()) {
+ const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ px = pathSnap.x;
+ py = pathSnap.y;
+ }
+ const joP = Number(p && p.spawnJoinOrder);
+ const spawnOrd = Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : Math.max(0, plist.findIndex((q) => q && q.id === p.id));
+ others.set(p.id, {
+ x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
+ spawnJoinOrder: spawnOrd,
+ playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p),
+ gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ gauntletEliminated: !!p.gauntletEliminated,
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(p.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(p.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!p.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ }
+ });
+ if (mapData.gameType === 'gauntlet') {
+ me.tx = me.x;
+ me.ty = me.y;
+ if (res.gauntletEndsAt != null) {
+ const geJoin = Number(res.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ } else {
+ me.tx = null;
+ me.ty = null;
+ gauntletEndsAtMs = null;
+ }
+ rebalancePreviewBots();
+ if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
+ applyQuizBattlePreviewSpawnLayout(false);
+ try { socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } catch (eSync) { /* ignore */ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (isStack()) {
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 50);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ playPath = [];
+ me.isWalking = false;
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 50);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossSessionStartMs = performance.now();
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ }
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (t) { if (t) applyGauntletTimingFromServer(t); })
+ .catch(function () {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 50);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR = document.getElementById('gauntlet-crown-countdown');
+ if (gccR) gccR.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) {
+ hideConflictingOverlaysForGauntletCrown();
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRm = document.getElementById('gauntlet-crown-countdown');
+ if (gccRm) gccRm.classList.add('is-hidden');
+ if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld });
+ }
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
+ } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) {
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccRj = document.getElementById('gauntlet-crown-countdown');
+ if (gccRj) gccRj.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ const gccRs = document.getElementById('gauntlet-crown-countdown');
+ if (gccRs) gccRs.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ const gccRq = document.getElementById('gauntlet-crown-countdown');
+ if (gccRq) gccRq.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ const gccRst = document.getElementById('gauntlet-crown-countdown');
+ if (gccRst) gccRst.classList.add('is-hidden');
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ } else {
+ const hov = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hov) {
+ hov.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccR2 = document.getElementById('gauntlet-crown-countdown');
+ if (gccR2) gccR2.classList.add('is-hidden');
+ teardownStackTowerMissionUiPlay();
+ }
+ resizeCanvas(); draw(); tick();
+ }
+
+ socket.on('connect', () => {
+ firstCharacterDefaultPromise.then(() => {
+ const joinPlayMapId = (params.get('map') || '').trim();
+ const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null)
+ || readStoredDisplayName()
+ || nick;
+ socket.emit('join-space', {
+ spaceId,
+ nickname: joinNick,
+ characterId: getPlayCharacterId(),
+ playMapId: joinPlayMapId || undefined,
+ playerKey: getJdPlayerKey(),
+ }, (res) => {
+ if (!res || !res.ok) {
+ const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
+ alert(errMsg);
+ const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
+ if (caseLocked) {
+ window.location.replace(buildRoomLobbyReturnHref());
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ return;
+ }
+ myId = socket.id;
+ if (specialQuizForceDebug) {
+ try { socket.emit('special-quiz-force', { on: true }); } catch (eF) { /* ignore */ }
+ }
+ if (__isTestModeOnPlay()) {
+ try { socket.emit('special-quiz-testmode', { on: true }); } catch (eT) { /* ignore */ }
+ }
+ let botSlots = parseInt(res.botSlotCount, 10);
+ if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
+ botSlots = 6 - res.maxPlayers;
+ }
+ if (botSlots > 0) {
+ detectiveCaseFillBots = true;
+ detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots));
+ }
+ if (isDetectiveMinigamePlay()) {
+ try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ }
+ }
+ if (res.quizSettingsSnap && typeof res.quizSettingsSnap === 'object') {
+ if (res.quizSettingsSnap.quizMapPanelTheme && typeof res.quizSettingsSnap.quizMapPanelTheme === 'object') {
+ setQuizMapPanelThemeFromApi({ quizMapPanelTheme: res.quizSettingsSnap.quizMapPanelTheme });
+ }
+ }
+ if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') {
+ quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap;
+ if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') {
+ setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme });
+ }
+ if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') {
+ setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme });
+ }
+ if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes });
+ } else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') {
+ setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme });
+ }
+ const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale);
+ if (Number.isFinite(joinSc)) {
+ quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc));
+ }
+ }
+ if (res.specialQuizIcon) setSpecialQuizIconFromServer(res.specialQuizIcon);
+ if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
+ const q = [];
+ q.push('space=' + encodeURIComponent(spaceId));
+ q.push('nick=' + encodeURIComponent(nick));
+ const mid = params.get('map');
+ if (mid) q.push('map=' + encodeURIComponent(mid));
+ q.push('preview=1');
+ q.push('editorEmbed=1');
+ if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
+ window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
+ return;
+ }
+ const playMapId = params.get('map');
+ if (playMapId) {
+ fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId))
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (data) applyMapAndStart(data, res);
+ else applyMapAndStart(res.mapData, res);
+ })
+ .catch(() => applyMapAndStart(res.mapData, res));
+ } else {
+ applyMapAndStart(res.mapData, res);
+ }
+ });
+ });
+ });
+
+ const LERP = 0.2;
+
+ function applyPlayDisplayNameUpdate(data) {
+ if (!data || !data.id) return;
+ const next = String(data.nickname || '').trim();
+ if (!next) return;
+ if (data.id === myId) {
+ me.nickname = next;
+ nick = next;
+ try {
+ localStorage.setItem('roomLobbyDisplayName', next);
+ localStorage.setItem('playerName', next);
+ } catch (e) { /* ignore */ }
+ if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') {
+ window.jdDisplayName.syncDom(next);
+ }
+ } else {
+ const o = others.get(data.id);
+ if (o) o.nickname = next;
+ }
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (typeof syncPlayCyberHud === 'function') syncPlayCyberHud();
+ }
+
+ socket.on('peer-display-name', applyPlayDisplayNameUpdate);
+
+ socket.on('lobby-tint-sync', applyPlayLobbyTintSync);
+
+ window.addEventListener('jd-display-name-changed', function (ev) {
+ const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : '';
+ if (!next || !myId) return;
+ applyPlayDisplayNameUpdate({ id: myId, nickname: next });
+ });
+
+ socket.on('user-joined', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ const x = Number(data.x);
+ const y = Number(data.y);
+ const px = Number.isFinite(x) ? x : 1;
+ const py = Number.isFinite(y) ? y : 1;
+ const joJ = Number(data && data.spawnJoinOrder);
+ const spawnOrdJ = Number.isFinite(joJ) ? Math.max(0, Math.floor(joJ)) : 0;
+ others.set(data.id, {
+ x: px, y: py, tx: px, ty: py,
+ direction: data.direction || 'down', nickname: data.nickname,
+ characterId: data.characterId ?? null,
+ spawnJoinOrder: spawnOrdJ,
+ playTint: playTintFromJoinPeer(data) || playTintForPreviewBotId(data.id) || resolvePlayTintForPeer(data.id, data),
+ gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
+ gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ jumpSurviveEliminated: false,
+ spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBossDmg: (() => { const s = Number(data.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
+ balloonBossBalloons: (() => {
+ const s = Number(data.balloonBossBalloons);
+ return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
+ })(),
+ balloonBossEliminated: !!data.balloonBossEliminated,
+ quizCarryHeld: null,
+ });
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('host-changed', (d) => {
+ if (d && d.hostId != null) playHostId = d.hostId;
+ if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('quiz-carry-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ quizCarryLobbyReadyMap = { ...d.readyMap };
+ if (quizCarryPregameActive && isQuizCarry()) {
+ quizCarrySyncGuestReadyIfNeeded();
+ updateQuizCarryPregameHud();
+ }
+ });
+ socket.on('quiz-carry-lobby-started', () => {
+ if (!quizCarryEmbedMissionFlowActive()) return;
+ endQuizCarryEmbedPregameAndStart();
+ });
+ socket.on('gauntlet-crown-lobby-sync', (d) => {
+ if (!d || typeof d.readyMap !== 'object') return;
+ gauntletCrownLobbyReadyMap = { ...d.readyMap };
+ if (gauntletCrownPregamePhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateGauntletCrownHowtoHud();
+ } else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateQuizQuestionMissionHowtoHud();
+ } else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateStackTowerMissionHowtoHud();
+ } else if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateJumpSurviveMissionHowtoHud();
+ } else if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ gauntletCrownSyncGuestReadyIfNeeded();
+ updateSpaceShooterMissionHowtoHud();
+ }
+ });
+ socket.on('gauntlet-crown-lobby-started', () => {
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ beginGauntletCrownCountdownThenRun();
+ });
+ socket.on('user-move', (data) => {
+ if (data && myId != null && data.id === myId) {
+ /* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
+ if (mapData && mapData.gameType === 'gauntlet') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'stack') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'jump_survive') {
+ return;
+ }
+ if (mapData && mapData.gameType === 'space_shooter') {
+ if (data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
+ }
+ return;
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) me.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
+ return;
+ }
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ if (quizBattleFinishLock) return; /* ถึงจุดจบแล้ว — เพิกเฉย echo ตำแหน่งของตัวเอง (กันเด้งกลับ) */
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) me.x = x;
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) me.y = y;
+ }
+ const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = s.x;
+ me.y = s.y;
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ if (data.x != null) {
+ const x = Number(data.x);
+ if (Number.isFinite(x)) {
+ me.x = x;
+ me.tx = me.x;
+ }
+ }
+ if (data.y != null) {
+ const y = Number(data.y);
+ if (Number.isFinite(y)) {
+ me.y = y;
+ me.ty = me.y;
+ }
+ }
+ if (data.direction) me.direction = data.direction;
+ if (data.characterId != null) me.characterId = data.characterId;
+ playPath = [];
+ return;
+ }
+ const o = others.get(data.id);
+ if (o) {
+ if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px) && Number.isFinite(py)) {
+ const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
+ o.tx = sn.x;
+ o.ty = sn.y;
+ o.x = sn.x;
+ o.y = sn.y;
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ }
+ } else {
+ o.tx = data.x;
+ o.ty = data.y;
+ if (mapData && isQuiz()) {
+ const px = Number(data.x);
+ const py = Number(data.y);
+ if (Number.isFinite(px)) o.x = px;
+ if (Number.isFinite(py)) o.y = py;
+ }
+ }
+ o.direction = data.direction || o.direction;
+ if (data.characterId != null) o.characterId = data.characterId;
+ if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
+ const s = Number(data.spaceShooterScore);
+ if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
+ }
+ if (mapData && mapData.gameType === 'balloon_boss') {
+ if (data.balloonBossScore != null) {
+ const s = Number(data.balloonBossScore);
+ if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
+ }
+ if (data.balloonBossBossDmg != null) {
+ const d = Number(data.balloonBossBossDmg);
+ if (Number.isFinite(d)) o.balloonBossBossDmg = Math.max(0, d);
+ }
+ if (data.balloonBossBalloons != null) {
+ const b = Number(data.balloonBossBalloons);
+ if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
+ }
+ if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
+ }
+ }
+ });
+ socket.on('user-left', (data) => {
+ if (!data || isPreviewBotId(data.id)) return;
+ others.delete(data.id);
+ if (playBotsEnabled()) rebalancePreviewBots();
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
+ if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
+ if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
+ if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
+ });
+ socket.on('chat', (data) => {
+ const box = document.getElementById('chat-messages');
+ if (!box) return;
+ const div = document.createElement('div');
+ div.className = 'chat-msg';
+ div.textContent = (data.nickname || '') + ': ' + (data.text || '');
+ box.appendChild(div);
+ box.scrollTop = 1e9;
+ });
+
+ socket.on('quiz-phase', (p) => {
+ if (previewMode) return;
+ if (!p || !mapData || !isQuiz()) return;
+ if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') {
+ quizQuestionMissionDeferredPhase = p;
+ return;
+ }
+ applyQuizPhaseFromServer(p);
+ });
+
+ socket.on('quiz-result', (r) => {
+ if (previewMode || !r) return;
+ if (r.scores) {
+ playLiveQuizScores = { ...r.scores };
+ renderPlayQuizScoreboard(playLiveQuizScores);
+ }
+ if (isQuiz() && Array.isArray(r.results)) {
+ r.results.forEach(function (row) {
+ if (!row || row.id == null) return;
+ if (!row.right) {
+ playQuizEverWrong[String(row.id)] = true;
+ const o = others.get(row.id);
+ if (o) {
+ o.quizCannotTrue = true;
+ o.quizCannotFalse = true;
+ }
+ }
+ });
+ }
+ if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) {
+ spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue);
+ }
+ showPlayQuizFeedback(r);
+ });
+
+ socket.on('quiz-player-state', (st) => {
+ if (previewMode) return;
+ if (!st) return;
+ playQuizPlayerLocal = {
+ cannotTrue: !!st.cannotTrue,
+ cannotFalse: !!st.cannotFalse,
+ eliminated: !!st.eliminated,
+ score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
+ };
+ if (quizGhostConfineActivePlay()) warpMeIntoQuizGhostZonePlay();
+ });
+
+ socket.on('quiz-ended', () => {
+ if (previewMode && isQuiz()) return;
+ let questionMissionEnd = null;
+ if (isQuizQuestionMissionUiMapPlay()) {
+ questionMissionEnd = quizQuestionMissionBuildPayload();
+ }
+ playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
+ playQuizPhaseLocal = null;
+ if (playQuizTimerInterval) {
+ clearInterval(playQuizTimerInterval);
+ playQuizTimerInterval = null;
+ }
+ const ov = document.getElementById('quiz-game-overlay');
+ if (ov) ov.classList.add('is-hidden');
+ const panel = document.getElementById('quiz-map-question-panel');
+ if (panel) {
+ panel.classList.add('is-hidden');
+ panel.setAttribute('aria-hidden', 'true');
+ }
+ playQuizText = '';
+ playQuizPhaseEndsAt = 0;
+ playLiveQuizScores = {};
+ hidePlayQuizHud();
+ if (questionMissionEnd) {
+ quizQuestionMissionPhase = 'ended';
+ quizQuestionMissionDeferredPhase = null;
+ applyQuizQuestionMissionPanelImages();
+ showGauntletCrownMissionOverlay(questionMissionEnd);
+ }
+ });
+
+ /* ===== Special Quiz ในเกม — socket events ===== */
+ socket.on('special-quiz-icon', (payload) => {
+ if (previewMode) return;
+ setSpecialQuizIconFromServer(payload);
+ });
+ socket.on('special-quiz-icon-clear', () => { clearSpecialQuizIcon(); });
+ socket.on('special-quiz-question', (q) => {
+ if (previewMode) return;
+ openSpecialQuizQuestion(q);
+ });
+ socket.on('special-quiz-answer-update', (d) => {
+ if (previewMode || !d || !specialQuizActiveQuestion) return;
+ markSpecialQuizMemberAnswered(d.id);
+ });
+ socket.on('special-quiz-result', (r) => {
+ if (previewMode) return;
+ handleSpecialQuizResult(r);
+ });
+ socket.on('special-quiz-ended', (data) => {
+ if (previewMode) return;
+ endSpecialQuiz(data);
+ });
+ socket.on('special-card-applied', (data) => {
+ if (previewMode || !data || !data.card) return;
+ applySpecialCardClient(data);
+ });
+
+ socket.on('gauntlet-sync', (data) => {
+ if (!data || typeof data !== 'object') return;
+ if (isMegaVirusMissionShellMapPlay()) {
+ applyGauntletTimingFromServer(data);
+ return;
+ }
+ if (!mapData || mapData.gameType !== 'gauntlet') return;
+ applyGauntletTimingFromServer(data);
+ if (Array.isArray(data.obstacles)) {
+ gauntletObstacles = data.obstacles;
+ pushGauntletObsRenderFrame(data.obstacles);
+ }
+ if (!Array.isArray(data.players)) return;
+ data.players.forEach((p) => {
+ if (!p || p.id == null || myId == null) return;
+ const pid = String(p.id);
+ const mid = String(myId);
+ if (pid === mid) {
+ const prevSc = Math.max(0, Number(me.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) me.tx = px;
+ if (Number.isFinite(py)) me.ty = py;
+ if (p.direction) me.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ me.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(me);
+ } else {
+ const o = others.get(p.id) ?? others.get(pid);
+ if (o) {
+ const prevSc = Math.max(0, Number(o.gauntletScore) || 0);
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) o.tx = px;
+ if (Number.isFinite(py)) o.ty = py;
+ if (p.direction) o.direction = p.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletScore = newSc;
+ if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
+ o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
+ }
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ clampGauntletCrownHeistEntityXInPlacePlay(o);
+ }
+ }
+ });
+ if (playBotsEnabled()) {
+ applyGauntletPreviewBotsAfterSync();
+ emitGauntletPreviewRowsToServer();
+ }
+ });
+
+ function showMinigameCoinsToast(mine) {
+ let el = document.getElementById('minigame-coins-toast');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'minigame-coins-toast';
+ el.style.cssText = 'position:fixed;left:50%;top:13%;transform:translateX(-50%);z-index:99999;padding:13px 26px;border-radius:14px;font-weight:700;text-align:center;pointer-events:none;box-shadow:0 6px 24px rgba(0,0,0,.45);transition:opacity .35s;opacity:0;font-size:20px;line-height:1.3;';
+ document.body.appendChild(el);
+ }
+ const survived = mine.survived !== false;
+ const coins = Math.max(0, Number(mine.coins) || 0);
+ if (survived && coins > 0) {
+ el.style.background = 'linear-gradient(135deg,#1b9e4b,#13c06a)';
+ el.style.color = '#fff';
+ el.innerHTML = '🪙 +' + coins + ' เหรียญคะแนนสะสม +' + coins + ' ';
+ } else if (survived) {
+ el.style.background = 'linear-gradient(135deg,#555,#777)';
+ el.style.color = '#fff';
+ el.textContent = 'รอบนี้ไม่ติดอันดับรางวัล';
+ } else {
+ el.style.background = 'linear-gradient(135deg,#a11,#d33)';
+ el.style.color = '#fff';
+ el.textContent = '💀 ตายแล้ว — ไม่ได้รับเหรียญ';
+ }
+ el.style.opacity = '1';
+ if (window.__miniCoinsToastT) clearTimeout(window.__miniCoinsToastT);
+ window.__miniCoinsToastT = setTimeout(() => { el.style.opacity = '0'; }, 4200);
+ }
+
+ socket.on('minigame-coins', (data) => {
+ try {
+ if (!data || !Array.isArray(data.results)) return;
+ const mine = data.results.find((r) => r.id === myId);
+ if (mine) showMinigameCoinsToast(mine);
+ } catch (e) { /* ignore */ }
+ });
+
+ socket.on('gauntlet-ended', (data) => {
+ cancelQuizCarryResultEndAfterTimeup();
+ gauntletEndsAtMs = null;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayBgStripFreezeSinceMs = 0;
+ gauntletCrownRunwayFinishAlignLatchedWorldX = null;
+ gauntletCrownRunwayClientMissionShown = false;
+ gauntletCrownRunHeldRemote = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ resetGauntletCrownRunwaySpawnScrollSnap();
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc = document.getElementById('gauntlet-crown-countdown');
+ if (gcc) gcc.classList.add('is-hidden');
+ const hto = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto) hto.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ if (data && data.crownMission) {
+ showGauntletCrownMissionOverlay(data.crownMission);
+ return;
+ }
+ const ov = document.getElementById('gauntlet-ended-overlay');
+ const msgEl = document.getElementById('gauntlet-ended-message');
+ const titleEl = document.getElementById('gauntlet-ended-title');
+ const listEl = document.getElementById('gauntlet-ended-rankings');
+ const btn = document.getElementById('btn-gauntlet-ended-lobby');
+ if (!ov || !msgEl || !listEl) return;
+ if (titleEl) {
+ titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
+ }
+ msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
+ listEl.innerHTML = '';
+ const ranks = (data && data.rankings) || [];
+ ranks.forEach((r, i) => {
+ const li = document.createElement('li');
+ const isMe = myId != null && r && String(r.id) === String(myId);
+ li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
+ if (isMe) li.className = 'gauntlet-ended-me';
+ listEl.appendChild(li);
+ });
+ ov.classList.remove('is-hidden');
+ function goLobby() {
+ window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
+ }
+ if (btn) {
+ btn.onclick = () => {
+ if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
+ else goLobby();
+ };
+ }
+ });
+
+ socket.on('game-start', (ev) => {
+ if (!ev || !ev.mapId) return;
+ const urlMap = (params.get('map') || '').trim();
+ if (urlMap && ev.stayInRoomLobby && String(ev.mapId).trim() !== urlMap) {
+ return;
+ }
+ const applySnap = (md) => {
+ mapData = md;
+ playEmbedUserZoomMul = 1;
+ if (ev.mapId != null && String(ev.mapId).trim() !== '') {
+ playSessionMapId = String(ev.mapId).trim();
+ }
+ applyPlayEmbedZoomForCurrentMapPlay();
+ if (!isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionPhase = null;
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ }
+ if (!isStackTowerMissionUiMapPlay()) {
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType !== 'space_shooter') {
+ spaceShooterGameEnded = false;
+ spaceShooterMissionPhase = null;
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ }
+ if (mapData.gameType !== 'balloon_boss') {
+ balloonBossGameEnded = false;
+ }
+ if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
+ tileSize = mapData.tileSize || 32;
+ gauntletObstacles = [];
+ gauntletObsRenderPrev = [];
+ gauntletObsRenderNext = [];
+ gauntletObsBlendT0 = 0;
+ mapBackgroundImg = null;
+ if (mapData.backgroundImage) {
+ mapBackgroundImg = new Image();
+ mapBackgroundImg.src = mapData.backgroundImage;
+ }
+ reloadPlayScrollBgFromMap();
+ reloadStackTowerScrollBgFromMap();
+ reloadGauntletCrownRunwayBgFromMap();
+ normalizeGridImageCellsOnMap(mapData);
+ loadMapGridImages();
+ if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
+ ev.peersSnap.forEach((p) => {
+ if (p.id != null && myId != null && String(p.id) === String(myId)) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { me.x = px; me.tx = px; }
+ if (Number.isFinite(py)) { me.y = py; me.ty = py; }
+ me.direction = p.direction || me.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ meGauntletJumpVis = meGauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ me.gauntletEliminated = !!p.gauntletEliminated;
+ } else {
+ const o = others.get(p.id) ?? others.get(String(p.id));
+ if (o) {
+ const px = Number(p.x);
+ const py = Number(p.y);
+ if (Number.isFinite(px)) { o.x = px; o.tx = px; }
+ if (Number.isFinite(py)) { o.y = py; o.ty = py; }
+ o.direction = p.direction || o.direction;
+ const jt = Number(p.gauntletJumpTicks);
+ o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
+ o.gauntletJumpVis = o.gauntletJumpTicks;
+ const sc = Number(p.gauntletScore);
+ o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
+ o.gauntletEliminated = !!p.gauntletEliminated;
+ }
+ }
+ });
+ } else {
+ meGauntletJumpTicks = 0;
+ meGauntletJumpVis = 0;
+ me.gauntletScore = 0;
+ me.gauntletEliminated = false;
+ others.forEach((o) => {
+ o.gauntletJumpTicks = 0;
+ o.gauntletJumpVis = 0;
+ o.gauntletScore = 0;
+ o.gauntletEliminated = false;
+ });
+ }
+ if (mapData.gameType === 'gauntlet') {
+ if (ev.gauntletEndsAt != null) {
+ const ge = Number(ev.gauntletEndsAt);
+ gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
+ } else {
+ gauntletEndsAtMs = null;
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {});
+ if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) {
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc0 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc0) gcc0.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ const hto2 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto2) hto2.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc1 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc1) gcc1.classList.add('is-hidden');
+ }
+ } else {
+ gauntletEndsAtMs = null;
+ const megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID;
+ const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay())
+ || (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay())
+ || (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay())
+ || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay())
+ || megaShellGs;
+ if (!skipHideCrownShell) {
+ const hto3 = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hto3) hto3.classList.add('is-hidden');
+ gauntletCrownHowtoVisible = false;
+ }
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gcc2 = document.getElementById('gauntlet-crown-countdown');
+ if (gcc2) gcc2.classList.add('is-hidden');
+ }
+ if (mapData.gameType !== 'gauntlet') {
+ me.tx = null;
+ me.ty = null;
+ }
+ const gauntletOv = document.getElementById('gauntlet-ended-overlay');
+ if (gauntletOv) gauntletOv.classList.add('is-hidden');
+ const gcmOv = document.getElementById('gauntlet-crown-mission-overlay');
+ if (gcmOv) gcmOv.classList.add('is-hidden');
+ if (mapData.gameType === 'stack') {
+ if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
+ if (!mapData.stackLandArea) mapData.stackLandArea = [];
+ rebalancePreviewBots();
+ lastStackTickMs = performance.now();
+ resetStackMinigameState();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ else reapplyStackMiniSizingFromGlobals();
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ })
+ .catch(() => {
+ if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
+ });
+ if (isStackTowerMissionUiMapPlay()) {
+ stackTowerMissionEndedOnce = false;
+ stackTowerMissionDeferredPhase = null;
+ if (stackTowerMissionCountdownTimer) {
+ clearTimeout(stackTowerMissionCountdownTimer);
+ stackTowerMissionCountdownTimer = null;
+ }
+ stackTowerMissionPhase = 'howto';
+ stackTowerSessionStartAt = 0;
+ hideConflictingOverlaysForGauntletCrown();
+ applyStackTowerMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 60);
+ } else {
+ teardownStackTowerMissionUiPlay();
+ }
+ } else {
+ stackMini = null;
+ teardownStackTowerMissionUiPlay();
+ }
+ if (mapData.gameType === 'jump_survive') {
+ if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
+ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
+ if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
+ if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
+ normalizeJumpSurvivePlatformAreaInPlay(mapData);
+ normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
+ normalizeJumpSurviveHazardAreaInPlay(mapData);
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ jumpSurviveEliminated = false;
+ jumpSurviveGameEnded = false;
+ if (jumperMissionCountdownTimer) {
+ clearTimeout(jumperMissionCountdownTimer);
+ jumperMissionCountdownTimer = null;
+ }
+ if (isJumpSurviveMissionUiMapPlay()) {
+ jumpSurviveMissionPhase = 'howto';
+ hideConflictingOverlaysForJumpSurviveMission();
+ applyJumpSurviveJumperPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60);
+ } else {
+ jumpSurviveMissionPhase = null;
+ jumpSurviveInitRuntime();
+ }
+ }
+ if (mapData.gameType === 'space_shooter') {
+ normalizeShooterSpawnSlotsInPlay(mapData);
+ if (spaceShooterMissionCountdownTimer) {
+ clearTimeout(spaceShooterMissionCountdownTimer);
+ spaceShooterMissionCountdownTimer = null;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ spaceShooterMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applySpaceShooterMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 60);
+ } else {
+ spaceShooterMissionPhase = null;
+ spaceShooterGameEnded = false;
+ spaceShooterBullets = [];
+ spaceShooterAsteroids = [];
+ spaceShooterAsteroidExplosions = [];
+ spaceShooterPopups = [];
+ spaceShooterLastTickMs = performance.now();
+ spaceShooterSpawnAccMs = 0;
+ spaceShooterFireCd = 0;
+ spaceShooterSessionStartMs = performance.now();
+ spaceShooterLastMoveEmit = 0;
+ }
+ }
+ if (mapData.gameType === 'quiz') {
+ if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
+ if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
+ if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
+ }
+ if (mapData.gameType === 'balloon_boss') {
+ balloonBossGameEnded = false;
+ normalizeBalloonBossPlayerSlotsInPlay(mapData);
+ balloonBossPendingShots = [];
+ balloonBossPlayerBullets = [];
+ balloonBossBossBullets = [];
+ balloonBossHitFx = [];
+ balloonBossScorePopups = [];
+ balloonBossLastTickMs = performance.now();
+ balloonBossPlayerFireCd = 0;
+ balloonBossLastMoveEmit = 0;
+ balloonBossBossFireAcc = 0;
+ me.balloonBossScore = 0;
+ me.balloonBossBossDmg = 0;
+ me.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ me.balloonBossEliminated = false;
+ others.forEach((o) => {
+ o.balloonBossScore = 0;
+ o.balloonBossBossDmg = 0;
+ o.balloonBossBalloons = balloonBossBalloonsStartPlay();
+ o.balloonBossEliminated = false;
+ });
+ if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) {
+ balloonBossSessionStartMs = 0;
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => {
+ if (t) applyGauntletTimingFromServer(t);
+ if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
+ applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
+ }
+ })
+ .catch(() => {});
+ gauntletCrownPregamePhase = null;
+ gauntletCrownLobbyReadyMap = {};
+ if (gauntletCrownCountdownTimer) {
+ clearTimeout(gauntletCrownCountdownTimer);
+ gauntletCrownCountdownTimer = null;
+ }
+ const gccMv = document.getElementById('gauntlet-crown-countdown');
+ if (gccMv) gccMv.classList.add('is-hidden');
+ setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
+ } else {
+ balloonBossSessionStartMs = performance.now();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ }
+ }
+ if (mapData.gameType === 'quiz_battle') {
+ if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+ if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
+ normalizeQuizBattlePathInPlay(mapData);
+ normalizeQuizBattleDomeInPlay(mapData);
+ buildQuizBattlePathGateRequiredPlay(mapData);
+ if (quizBattlePathModeActive(mapData)) {
+ const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
+ me.x = ms.x;
+ me.y = ms.y;
+ others.forEach((o) => {
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ });
+ }
+ }
+ var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
+ : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
+ : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
+ var prevTag = previewMode ? '[ทดสอบ] ' : '';
+ document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
+ if (isQuiz()) {
+ setupPlayQuizUi();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ quizQuestionMissionDeferredPhase = null;
+ if (quizQuestionMissionCountdownTimer) {
+ clearTimeout(quizQuestionMissionCountdownTimer);
+ quizQuestionMissionCountdownTimer = null;
+ }
+ quizQuestionMissionPhase = 'howto';
+ hideConflictingOverlaysForGauntletCrown();
+ applyQuizQuestionMissionPanelImages();
+ fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((t) => { if (t) applyGauntletTimingFromServer(t); })
+ .catch(() => {});
+ setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 60);
+ }
+ } else if (isQuizCarry()) setupPlayQuizCarryUi();
+ else if (isQuizBattle()) setupPlayQuizBattleUi();
+ else teardownPlayQuizUi();
+ resizeCanvas();
+ if (mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'gauntlet') {
+ applyGauntletPreviewSpawnLayout(true);
+ emitGauntletPreviewRowsToServer();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'jump_survive') {
+ applyJumpSurvivePreviewSpawnLayout(true);
+ }
+ if (playBotsEnabled() && mapData.gameType === 'space_shooter') {
+ applySpaceShooterSpawnLayoutPlay();
+ }
+ if (playBotsEnabled() && mapData.gameType === 'balloon_boss') {
+ applyBalloonBossSpawnLayoutPlay();
+ }
+ };
+ fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
+ .then((r) => (r.ok ? r.json() : null))
+ .then((md) => { if (md) applySnap(md); });
+ });
+
+ function resizeCanvas() {
+ if (mapData && isStackTowerMissionUiMapPlay() && canvas) {
+ canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W);
+ canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H);
+ /* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
+ stackTowerScrollBgSrawWorldBaselinePlay = null;
+ stackTowerScrollBgSyncInitialScrollToBottom();
+ syncQuizCarryEmbedCountdownLayout();
+ return;
+ }
+ const vw = window.innerWidth || document.documentElement.clientWidth || 800;
+ const vh = window.innerHeight || document.documentElement.clientHeight || 600;
+ const header = document.querySelector('.game-header');
+ const headerH = playHideTopChrome() ? 0 : (header && header.offsetHeight ? header.offsetHeight : 48);
+ const stage = document.getElementById('play-canvas-stage');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const parent = stage || (canvas && canvas.parentElement);
+ let cw = canvas.clientWidth || 0;
+ let ch = canvas.clientHeight || 0;
+ if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
+ if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
+ /* จอใหญ่ / iframe embed: client บางทียัง 0 ช้ากว่า layout — ใช้ offset เป็นทางเลือก */
+ if (parent) {
+ const ow = parent.offsetWidth || 0;
+ const oh = parent.offsetHeight || 0;
+ if (ow > 80) cw = Math.max(cw, ow);
+ if (oh > 80) ch = Math.max(ch, oh);
+ }
+ /* embed: stage อาจยัง 0 แต่ stack วัดได้แล้ว — อย่าให้ fallback เป็น vw/vh เต็มจอแล้ว CSS ย่อเข้า stage = ดูชิดมุม */
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ const sh = Math.max(stackEl.clientHeight || 0, stackEl.offsetHeight || 0);
+ if (sw > 80) cw = Math.max(cw, sw);
+ if (sh > 80) ch = Math.max(ch, sh);
+ }
+ if (ch < 120) ch = Math.max(240, vh - headerH - 6);
+ if (cw < 120) {
+ if (previewMode && editorEmbedReturn && stackEl) {
+ const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
+ cw = sw > 80 ? Math.max(320, sw) : Math.max(320, vw);
+ } else cw = Math.max(320, vw);
+ }
+ canvas.width = Math.max(320, cw);
+ canvas.height = Math.max(240, ch);
+ syncQuizCarryEmbedCountdownLayout();
+ const hud = document.getElementById('play-cyber-hud');
+ if (hud && hud.classList.contains('play-cyber-hud--quiz-tf-mock')) {
+ _quizTfScaleLast = 0;
+ updateQuizTfMockScale(hud);
+ }
+ }
+
+ canvas.addEventListener('click', (e) => {
+ if (!mapData || !isStack() || isChatFocused()) return;
+ tryHumanStackDrop();
+ });
+ (function setupPlayEditorEmbedViewZoom() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const wheelZoomHandler = (e) => {
+ if (!mapData) return;
+ const embedZoom = previewMode && editorEmbedReturn;
+ const qbZoom = isQuizBattle();
+ if (!embedZoom && !qbZoom) return;
+ if (isStackTowerEmbedZoomLockedPlay()) return;
+ if (isQuizQuestionMissionHudActivePlay()) return;
+ const t = e.target;
+ if (t && typeof t.closest === 'function') {
+ if (t.closest('button, input, textarea, select, a[href]')) return;
+ }
+ /* ล้อธรรม / trackpad pinch มักไม่มี ctrl — ให้ซูมได้; Ctrl/⌘+ล้อยังใช้ได้ */
+ e.preventDefault();
+ e.stopPropagation();
+ const raw = e.deltaMode === 1 ? e.deltaY * 14 : e.deltaMode === 2 ? e.deltaY * 320 : e.deltaY;
+ const k = Math.exp(-raw * 0.002);
+ if (qbZoom) {
+ quizBattleUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * k),
+ );
+ } else {
+ playEmbedUserZoomMul = Math.max(
+ PLAY_EMBED_USER_ZOOM_MIN,
+ Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * k),
+ );
+ }
+ draw();
+ };
+ stack.addEventListener('wheel', wheelZoomHandler, { passive: false, capture: true });
+ })();
+
+ /* กด/แตะไอคอนควิซพิเศษ — ทำงานทุกมินิเกม (กันเคสเดินไปแตะไม่ได้ เช่น space shooter) */
+ canvas.addEventListener('pointerdown', (e) => {
+ if (trySpecialQuizClickAt(e.clientX, e.clientY)) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }, true);
+
+ canvas.addEventListener('dblclick', (e) => {
+ if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) return;
+ const r = canvas.getBoundingClientRect();
+ const sx = e.clientX - r.left;
+ const sy = e.clientY - r.top;
+ const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
+ const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
+ const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
+ const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
+ const zHit = lastPlayZDrawForInput > 0 ? lastPlayZDrawForInput : zoom;
+ const gx = (sx - canvas.width / 2) / zHit + camX;
+ const gy = (sy - canvas.height / 2) / zHit + camY;
+ const tx = Math.floor(gx / tileSize);
+ const ty = Math.floor(gy / tileSize);
+ const mw = mapData.width || 20, mh = mapData.height || 15;
+ if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
+ if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
+ const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
+ if (path.length <= 1) return;
+ playPath = path.slice(1);
+ });
+
+ function getCharacterFootprintWH(md) {
+ if (!md) return { cw: 1, ch: 1 };
+ let cw = Math.floor(Number(md.characterCellsW));
+ let ch = Math.floor(Number(md.characterCellsH));
+ const hasW = Number.isFinite(cw) && cw >= 1;
+ const hasH = Number.isFinite(ch) && ch >= 1;
+ if (hasW && hasH) {
+ return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
+ }
+ const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
+ return { cw: leg, ch: leg };
+ }
+
+ /** Quiz Battle: กล่องชน/เส้นทางตามสไปรต์ที่วาดจริง (drawAvatar ใช้ cw×1.15, ch×1.35 จากเท้า) */
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15;
+ const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35;
+
+ function quizBattleSpriteBoundsTilesPlay(md, px, py) {
+ const s = new Set();
+ if (!md || md.gameType !== 'quiz_battle') return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cx = px + cw * 0.5;
+ const feetY = py + ch;
+ const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2;
+ const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H;
+ const bottom = feetY;
+ if (left < 0 || right > w || top < 0 || bottom > h) return s;
+ const minTx = Math.floor(left);
+ const maxTx = Math.min(w - 1, Math.floor(right - 1e-6));
+ const minTy = Math.floor(top);
+ const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6));
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) s.add(tx + ',' + ty);
+ }
+ return s;
+ }
+
+ /**
+ * ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง
+ */
+ function getCharacterCollisionFootprintWH(md) {
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let colW = Math.floor(Number(md.characterCollisionW));
+ let colH = Math.floor(Number(md.characterCollisionH));
+ if (!Number.isFinite(colW) || colW < 1) colW = cw;
+ if (!Number.isFinite(colH) || colH < 1) colH = ch;
+ colW = Math.max(1, Math.min(cw, colW));
+ colH = Math.max(1, Math.min(ch, colH));
+ return { cw, ch, colW, colH };
+ }
+
+ function getGauntletCrownHeistMaxEntityXPlay(md) {
+ if (!md || !isGauntletCrownHeistMapPlay()) return null;
+ const w = md.width || 20;
+ const { cw } = getCharacterFootprintWH(md);
+ const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw;
+ const fullMapMax = Math.max(0, w - cw + 0.999);
+ return Math.max(0, Math.min(fullMapMax, capRight));
+ }
+ function clampGauntletCrownHeistEntityXInPlacePlay(ent) {
+ if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return;
+ const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
+ if (mx == null || !Number.isFinite(mx)) return;
+ if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x));
+ if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx));
+ }
+
+ /** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
+ function clampPlayEntityFootprintToMap(oEnt, md) {
+ if (!oEnt || !md) return;
+ const w = md.width || 20, h = md.height || 15;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ let maxX = Math.max(0, w - cw + 0.999);
+ const crownMx = getGauntletCrownHeistMaxEntityXPlay(md);
+ if (crownMx != null) maxX = Math.min(maxX, crownMx);
+ const maxY = Math.max(0, h - ch + 0.999);
+ if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
+ if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
+ oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
+ oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
+ }
+
+ /** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
+ function separateClumpedPreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const ids = [...others.keys()].filter(isPreviewBotId);
+ const minD = 0.52;
+ const pushAmt = MOVE_SPEED * 0.5;
+ for (let i = 0; i < ids.length; i++) {
+ for (let j = i + 1; j < ids.length; j++) {
+ const a = others.get(ids[i]);
+ const b = others.get(ids[j]);
+ if (!a || !b) continue;
+ let ddx = b.x - a.x, ddy = b.y - a.y;
+ let d = Math.sqrt(ddx * ddx + ddy * ddy);
+ if (d >= minD) continue;
+ if (d < 1e-5) {
+ ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
+ ddy = ((i + j) % 3) - 1;
+ d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
+ }
+ const ux = -ddx / d, uy = -ddy / d;
+ const vx = ddx / d, vy = ddy / d;
+ const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
+ const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
+ if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
+ if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
+ clampPlayEntityFootprintToMap(a, mapData);
+ clampPlayEntityFootprintToMap(b, mapData);
+ }
+ }
+ }
+
+ function quizTilesFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const maxTx = Math.min(w - 1, minTx + cw - 1);
+ const maxTy = Math.min(h - 1, minTy + ch - 1);
+ for (let ty = minTy; ty <= maxTy; ty++) {
+ for (let tx = minTx; tx <= maxTx; tx++) {
+ if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ /** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */
+ function playEntityFootprintContainsTile(px, py, tx, ty) {
+ return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /**
+ * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
+ * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
+ * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
+ */
+ function resolveBlockPlayerNoOverlapBoxWH(md) {
+ const m = md || mapData;
+ if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
+ const { cw, ch } = getCharacterFootprintWH(m);
+ let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
+ let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
+ const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
+ const hasW = Number.isFinite(w) && w >= 0;
+ const hasH = Number.isFinite(h) && h >= 0;
+ if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
+ w = Math.min(leg, cw);
+ h = Math.min(leg, ch);
+ } else {
+ if (!hasW) w = 0;
+ if (!hasH) h = 0;
+ }
+ const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
+ const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
+ return { cw, ch, boxW, boxH };
+ }
+
+ /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
+ function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - boxW) / 2);
+ const offY = minTy + (ch - boxH);
+ for (let ty = offY; ty < offY + boxH; ty++) {
+ for (let tx = offX; tx < offX + boxW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
+ return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
+ }
+
+ /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
+ function quizTilesWallCollisionFootprintPlay(px, py) {
+ if (mapData && mapData.gameType === 'quiz_battle') {
+ return quizBattleSpriteBoundsTilesPlay(mapData, px, py);
+ }
+ const s = new Set();
+ if (!mapData) return s;
+ if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
+ const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const minTx = Math.floor(px);
+ const minTy = Math.floor(py);
+ const offX = minTx + Math.floor((cw - colW) / 2);
+ const offY = minTy + (ch - colH);
+ for (let ty = offY; ty < offY + colH; ty++) {
+ for (let tx = offX; tx < offX + colW; tx++) {
+ if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
+ }
+ }
+ return s;
+ }
+
+ function quizAnswerTileForbiddenForLock(lock, tx, ty) {
+ if (!lock || lock.eliminated) return false;
+ if (!mapData) return false;
+ const qt = mapData.quizTrueArea;
+ const qf = mapData.quizFalseArea;
+ if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true;
+ if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true;
+ return false;
+ }
+
+ function quizAnswerTileForbiddenPlay(tx, ty) {
+ if (!playQuizPlayerLocal) return false;
+ return quizAnswerTileForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, tx, ty);
+ }
+
+ /** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */
+ function quizLockFootprintBlocksForLock(lock, px, py) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ for (const k of quizTilesFootprintPlay(px, py)) {
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockFootprintBlocksPlay(px, py) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockFootprintBlocksForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, px, py);
+ }
+
+ /** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */
+ function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) {
+ if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
+ const fromS = quizTilesFootprintPlay(ox, oy);
+ const toS = quizTilesFootprintPlay(nx, ny);
+ for (const k of toS) {
+ if (fromS.has(k)) continue;
+ const p = k.split(',');
+ const txi = +p[0], tyi = +p[1];
+ if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
+ }
+ return false;
+ }
+
+ function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) {
+ if (!playQuizPlayerLocal) return false;
+ return quizLockWouldEnterForbiddenForLock({
+ cannotTrue: !!playQuizPlayerLocal.cannotTrue,
+ cannotFalse: !!playQuizPlayerLocal.cannotFalse,
+ eliminated: !!playQuizPlayerLocal.eliminated,
+ }, ox, oy, nx, ny);
+ }
+
+ function botQuizLock(o) {
+ return {
+ cannotTrue: !!(o && o.quizCannotTrue),
+ cannotFalse: !!(o && o.quizCannotFalse),
+ eliminated: false,
+ };
+ }
+
+ /** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
+ function canWalkLikeLobby(x, y, fromX, fromY) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTiles = quizTilesWallCollisionFootprintPlay(x, y);
+ if (wallTiles.size === 0) return false;
+ for (const k of wallTiles) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTiles.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, o] of others) {
+ if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false;
+ }
+ }
+ }
+ if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksPlay(x, y)) {
+ return false;
+ }
+ }
+ if (quizGhostConfineActivePlay() && !quizGhostFootprintInsidePlay(x, y)) return false;
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) {
+ quizBattleFlashGateBlocked(mapData, x, y, quizBattlePassedComps);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
+ if (!mapData || !mapData.objects) return false;
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y);
+ for (const k of wallTilesB) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
+ const row = mapData.objects[ty];
+ if (!row || row[tx] === 1) return false;
+ }
+ if (wallTilesB.size === 0) return false;
+ if (!isQuizBattle()) {
+ const { colW, colH } = getCharacterCollisionFootprintWH(mapData);
+ if (wallTilesB.size < colW * colH) return false;
+ }
+ const bp = mapData.blockPlayer;
+ if (bp) {
+ for (const k of quizTilesFootprintPlay(x, y)) {
+ const p = k.split(',');
+ const tx = +p[0], ty = +p[1];
+ if (!bp[ty] || bp[ty][tx] !== 1) continue;
+ for (const [, peer] of others) {
+ if (o && peer === o) continue;
+ if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false;
+ }
+ if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false;
+ }
+ }
+ if (isQuiz()) {
+ const lock = botQuizLock(o);
+ if (lock.cannotTrue || lock.cannotFalse) {
+ const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
+ if (hasFrom) {
+ if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false;
+ } else if (quizLockFootprintBlocksForLock(lock, x, y)) {
+ return false;
+ }
+ }
+ }
+ if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ if (!quizBattlePositionAllowedPlay(mapData, x, y)) return false;
+ if (o && isPreviewBotId(o.id || o.peerId)) { /* preview bots ไม่ล็อกประตู */ }
+ else if (!quizBattlePathGateAllowsPlay(mapData, x, y, quizBattlePassedComps)) return false;
+ }
+ return true;
+ }
+
+ /** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */
+ function snapPositionOutOfQuizCarryHubIfNeeded(x, y) {
+ if (!mapData || !isQuizCarry()) return { x, y };
+ if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y };
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const maxR = Math.max(w, h) + 6;
+ for (let r = 1; r <= maxR; r++) {
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
+ const nx = x + dx * 0.45;
+ const ny = y + dy * 0.45;
+ if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue;
+ if (quizCarryFootprintOverlapsHub(nx, ny)) continue;
+ if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue;
+ return { x: nx, y: ny };
+ }
+ }
+ }
+ return { x, y };
+ }
+
+ function stepPreviewBotAlongPath(o, w, h) {
+ const path = o.botPath;
+ if (!path || !path.length) return;
+ const way = path[0];
+ const dx = way.x - o.x, dy = way.y - o.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ path.shift();
+ while (path.length > 0) {
+ const w2 = path[0];
+ const ux = w2.x - o.x, uy = w2.y - o.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ path.shift();
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ return;
+ }
+ const len = dist || 1;
+ const carryWalk = isQuizCarry() ? quizCarryWalkSpeedMultActive : 1;
+ const baseEmbedCarryPathMul = 0.74;
+ const pathStepMul = (previewFillBots && editorEmbedReturn)
+ ? (isQuizCarry() ? baseEmbedCarryPathMul * (quizCarryWalkSpeedMultActive / QUIZ_CARRY_WALK_SPEED_MULT) : 0.56)
+ : 1;
+ const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul * carryWalk, len);
+ const nx = o.x + (dx / len) * step;
+ const ny = o.y + (dy / len) * step;
+ if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
+ else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
+ const ox = o.x, oy = o.y;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, nx, ny, (px, py, fx, fy) => canWalkLikeLobbyForBot(px, py, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ } else if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
+ o.x = nx;
+ o.y = ny;
+ } else {
+ if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
+ o.x = nx;
+ } else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
+ o.y = ny;
+ } else {
+ /* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */
+ const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul * carryWalk, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
+ const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
+ const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
+ const tryAxisX = () => {
+ if (sx === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
+ o.x += sx * cstep;
+ return true;
+ };
+ const tryAxisY = () => {
+ if (sy === 0) return false;
+ if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
+ o.y += sy * cstep;
+ return true;
+ };
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ if (!tryAxisX()) tryAxisY();
+ } else {
+ if (!tryAxisY()) tryAxisX();
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
+ o.botIsWalking = true;
+ o.botPathStuckTicks = 0;
+ } else {
+ o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
+ if ((o.botPathStuckTicks | 0) >= 20) {
+ o.botPathStuckTicks = 0;
+ if (path.length) path.shift();
+ if (isQuizCarry() && playBotsEnabled()) o.botQuizCarryPathfindAfter = 0;
+ }
+ }
+ }
+
+ function pickRandomPreviewBotWanderDir() {
+ const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
+ return dirs[Math.floor(Math.random() * dirs.length)];
+ }
+
+ function stepPreviewBots() {
+ if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
+ if (isQuizCarry()) {
+ stepQuizCarryPreviewBots();
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const now = Date.now();
+ const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ o.botIsWalking = false;
+ /* บอทที่ตอบผิดไปแล้วในแมป mng8a80o → เป็นผีเดินมั่ว ไม่ต้องไปยืนรอที่โซนตอบ */
+ const ghostWanderBot = isQuizQuestionMissionUiMapPlay() && isQuizWrongGhostActiveForEnt(id);
+ if (ghostWanderBot) {
+ /* ข้าม path-following → ตกไป wander logic ด้านล่าง */
+ o.botPath = [];
+ o.botAnswerWander = true;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) {
+ stepPreviewBotAlongPath(o, w, h);
+ return;
+ }
+ if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) {
+ return;
+ }
+ /* ก่อนตอบ / พัก / บอทสับสน / ผี (ตอบผิดไปแล้ว): เดินทุกเฟรมตามทิศ */
+ if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
+ if (now >= o.botWanderNextTurn) {
+ o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
+ if (Math.random() < 0.55) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ }
+ }
+ const accX = o.botWanderDx;
+ const accY = o.botWanderDy;
+ if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
+ const step = MOVE_SPEED;
+ const tox = o.x + accX * step;
+ const toy = o.y + accY * step;
+ const ox = o.x, oy = o.y;
+ const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrictW) {
+ const moved = quizBattleTryStepMovePlay(o.x, o.y, tox, toy, (nx, ny, fx, fy) => canWalkLikeLobbyForBot(nx, ny, fx, fy, o));
+ o.x = moved.x;
+ o.y = moved.y;
+ if (Math.abs(o.x - ox) < 1e-5 && Math.abs(o.y - oy) < 1e-5) {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ } else if (canWalkLikeLobbyForBot(tox, toy, o.x, o.y, o)) {
+ o.x = tox;
+ o.y = toy;
+ } else {
+ if (canWalkLikeLobbyForBot(tox, o.y, o.x, o.y, o)) {
+ o.x = tox;
+ } else if (canWalkLikeLobbyForBot(o.x, toy, o.x, o.y, o)) {
+ o.y = toy;
+ } else {
+ const d = pickRandomPreviewBotWanderDir();
+ o.botWanderDx = d[0];
+ o.botWanderDy = d[1];
+ o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
+ }
+ }
+ if (!Number.isFinite(o.x)) o.x = 0.5;
+ if (!Number.isFinite(o.y)) o.y = 0.5;
+ clampPlayEntityFootprintToMap(o, mapData);
+ if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
+ });
+ separateClumpedPreviewBots();
+ [...others.keys()].filter(isPreviewBotId).forEach((bid) => {
+ const ob = others.get(bid);
+ if (ob) clampPlayEntityFootprintToMap(ob, mapData);
+ });
+ if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id)) return;
+ const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
+ o.x = s.x;
+ o.y = s.y;
+ o.tx = s.x;
+ o.ty = s.y;
+ });
+ }
+ }
+
+ /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */
+ function pathfindPlay(fromX, fromY, toX, toY) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ function pathfindPlayForBot(fromX, fromY, toX, toY, o) {
+ if (!mapData) return [];
+ const w = mapData.width || 20, h = mapData.height || 15;
+ const fx = Math.floor(fromX), fy = Math.floor(fromY);
+ const tx = Math.floor(toX), ty = Math.floor(toY);
+ if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return [];
+ if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
+ const key = (gx, gy) => gx + ',' + gy;
+ const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
+ const closed = new Set();
+ const cameFrom = {};
+ const gScore = { [key(fx, fy)]: 0 };
+ const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
+ const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
+ while (open.length) {
+ open.sort((a, b) => a.f - b.f);
+ const cur = open.shift();
+ const ck = key(cur.gx, cur.gy);
+ if (closed.has(ck)) continue;
+ closed.add(ck);
+ if (cur.gx === tx && cur.gy === ty) {
+ const path = [];
+ let u = cur;
+ while (u) {
+ path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
+ u = cameFrom[key(u.gx, u.gy)];
+ }
+ return path;
+ }
+ for (const d of dirs) {
+ const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
+ if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue;
+ const nk = key(nx, ny);
+ if (closed.has(nk)) continue;
+ const g = (gScore[ck] ?? Infinity) + 1;
+ if (g >= (gScore[nk] ?? Infinity)) continue;
+ gScore[nk] = g;
+ cameFrom[nk] = cur;
+ open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
+ }
+ }
+ return [];
+ }
+
+ let playPath = [];
+
+ /** เลเซอร์ = รูป cut obstacles-1.png (emitter หัว + ลำแสง + emitter ท้าย ในรูปเดียว) ยืดเต็มความสูง
+ ถ้ารูปยังไม่โหลด → fallback ลำแสงแดงวาดเอง */
+ function drawGauntletLaserColumnScreen(rx, ry, rw, rh) {
+ if (rh <= 1 || rw <= 0) return;
+ const cx = rx + rw / 2;
+ const spriteRec = ensureGauntletAssetImage(BASE + '/img/gauntlet-assets/obstacles-1.png');
+ if (spriteRec && spriteRec.img.complete && spriteRec.img.naturalWidth > 0) {
+ const drawW = Math.max(14, rw * 1.5);
+ try { ctx.drawImage(spriteRec.img, cx - drawW / 2, ry, drawW, rh); } catch (e) { /* ignore */ }
+ return;
+ }
+ ctx.save();
+ const coreW = Math.max(2, rw * 0.30);
+ const glowW = Math.max(coreW + 4, rw * 0.92);
+ const prevComp = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ /* glow รอบนอก */
+ let g1 = ctx.createLinearGradient(cx - glowW / 2, 0, cx + glowW / 2, 0);
+ g1.addColorStop(0, 'rgba(255,40,60,0)');
+ g1.addColorStop(0.5, 'rgba(255,55,75,0.5)');
+ g1.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = g1;
+ ctx.fillRect(cx - glowW / 2, ry, glowW, rh);
+ /* แกนสว่าง */
+ let g2 = ctx.createLinearGradient(cx - coreW / 2, 0, cx + coreW / 2, 0);
+ g2.addColorStop(0, 'rgba(255,110,130,0.85)');
+ g2.addColorStop(0.5, 'rgba(255,240,245,0.98)');
+ g2.addColorStop(1, 'rgba(255,110,130,0.85)');
+ ctx.fillStyle = g2;
+ ctx.fillRect(cx - coreW / 2, ry, coreW, rh);
+ ctx.globalCompositeOperation = prevComp;
+ /* หัว/ท้าย emitter — รูปจริง gauntlet-dd51 (gauntletLaserTopUrl/BottomUrl) ที่บน-ล่างสุดของลำแสง
+ กล่องเป็นสีเข้ม → ใส่ glow แดงข้างหลังให้เด่นบนพื้นมืด (ผนัง museum) */
+ const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null;
+ const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null;
+ const capW = Math.max(18, rw * 2.0);
+ function laserEmitterGlow(gy) {
+ const rad = capW * 0.8;
+ const pc = ctx.globalCompositeOperation;
+ ctx.globalCompositeOperation = 'lighter';
+ const gg = ctx.createRadialGradient(cx, gy, 0, cx, gy, rad);
+ gg.addColorStop(0, 'rgba(255,120,140,0.9)');
+ gg.addColorStop(0.5, 'rgba(255,55,75,0.55)');
+ gg.addColorStop(1, 'rgba(255,40,60,0)');
+ ctx.fillStyle = gg;
+ ctx.beginPath();
+ ctx.arc(cx, gy, rad, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalCompositeOperation = pc;
+ }
+ if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) {
+ const ch = capW * topRec.img.naturalHeight / topRec.img.naturalWidth;
+ laserEmitterGlow(ry + ch * 0.35);
+ try { ctx.drawImage(topRec.img, cx - capW / 2, ry - ch * 0.1, capW, ch); } catch (e) { /* ignore */ }
+ }
+ if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) {
+ const ch = capW * botRec.img.naturalHeight / botRec.img.naturalWidth;
+ laserEmitterGlow(ry + rh - ch * 0.35);
+ /* ท้าย — กลับหัวรูป (เครื่องยิงหันขึ้น) วางชิดล่างสุด */
+ ctx.save();
+ ctx.translate(cx, ry + rh - ch * 0.9);
+ ctx.scale(1, -1);
+ try { ctx.drawImage(botRec.img, -capW / 2, 0, capW, ch); } catch (e) { /* ignore */ }
+ ctx.restore();
+ }
+ ctx.restore();
+ }
+
+ /** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */
+ function drawStackPreviewCyberHud(ctx, cw, ch) {
+ if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return;
+ const now = Date.now();
+ const pad = 12;
+ const currentSeat = getStackPreviewCurrentSeat();
+ const order = stackPreviewTurnOrder;
+ const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace';
+ const monoSm = '9px ui-monospace, Consolas, monospace';
+ const towerMapHud = isStackTowerMissionUiMapPlay();
+ /* MG3 live mission: HUD ย้ายไป DOM (#mg3-tower-hud) แล้ว — ไม่วาดบน canvas (object-fit:cover ครอปขอบหาย) */
+ if (towerMapHud) return;
+ const lifeSlots = towerMapHud
+ ? Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)))
+ : 3;
+ const livesRem = towerMapHud && stackMini.teamMissesLeft != null
+ ? Math.max(0, Number(stackMini.teamMissesLeft) || 0)
+ : Math.max(0, stackMini.lives != null ? stackMini.lives : 3);
+ const teamScore = stackMini.score || 0;
+ const combo = stackMini.combo || 0;
+
+ function roundRectPath(x, y, w, h, r) {
+ const rr = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ ctx.closePath();
+ }
+
+ /** @param {{ headBust?: boolean, headSrcFrac?: number, bustLiftPx?: number }} [avatarOpts] — headBust + bustLiftPx ดันรูปขึ้นในกรอบ */
+ function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint, avatarOpts) {
+ const timeMs = now;
+ const dir = 'down';
+ const rawImg = getAvatarImg(characterId, dir, timeMs, false);
+ const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
+ let iw = 0; let ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ const headBust = !!(avatarOpts && avatarOpts.headBust);
+ let sx = 0;
+ let sy = 0;
+ let sww = iw;
+ let shh = ih;
+ if (headBust && iw > 0 && ih > 0) {
+ const rawF = Number(avatarOpts && avatarOpts.headSrcFrac);
+ const frac = Number.isFinite(rawF) && rawF > 0.22 && rawF < 0.85 ? rawF : 0.4;
+ shh = Math.max(8, Math.round(ih * frac));
+ }
+ const ax = cx - maxSize / 2;
+ const ay = baselineY - maxSize;
+ ctx.save();
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.clip();
+ if (iw > 0 && ih > 0) {
+ const maxScale = headBust ? 2.45 : 1;
+ const scale = Math.min(maxSize / sww, maxSize / shh, maxScale);
+ const dw = sww * scale;
+ const dh = shh * scale;
+ const rawLift = headBust && avatarOpts ? Number(avatarOpts.bustLiftPx) : NaN;
+ const bustLift = headBust
+ ? (Number.isFinite(rawLift) ? Math.max(0, Math.min(maxSize * 0.45, rawLift)) : 22)
+ : 0;
+ ctx.drawImage(charImg, sx, sy, sww, shh, cx - dw / 2, baselineY - dh - bustLift, dw, dh);
+ } else {
+ ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
+ ctx.fillRect(ax, ay, maxSize, maxSize);
+ }
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(ax, ay, maxSize, maxSize, 5);
+ ctx.stroke();
+ }
+
+ function resolveEntryVisual(entry) {
+ if (entry.kind === 'human') {
+ return {
+ name: String(me.nickname || 'YOU').toUpperCase(),
+ score: me.stackPreviewHumanPts || 0,
+ characterId: me.characterId,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId || 'me')),
+ };
+ }
+ const o = entry.botId ? others.get(entry.botId) : null;
+ return {
+ name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
+ score: o ? (o.stackBotScore || 0) : 0,
+ characterId: o ? o.characterId : null,
+ playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
+ };
+ }
+
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+ const boardW = Math.min(248, Math.max(200, cw * 0.36));
+ const rowH = 42;
+ const headH = 38;
+ const boardH = headH + STACK_PREVIEW_TURN_COUNT * rowH + 10;
+
+ if (!towerMapHud) {
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, 8, boardW, boardH, 8);
+ ctx.stroke();
+
+ ctx.fillStyle = '#5cefff';
+ ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('SCORE', pad + 14, 16);
+ ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
+ ctx.font = monoSm;
+ ctx.fillText('shared stack · P1–P6', pad + 62, 18);
+
+ let rowY = 8 + headH;
+ const drawRows = (entries) => {
+ entries.forEach((entry) => {
+ const isCurrent = entry.seat === currentSeat;
+ const v = resolveEntryVisual(entry);
+ let actionFlash = false;
+ if (entry.kind === 'human') {
+ actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
+ } else if (entry.botId) {
+ const ob = others.get(entry.botId);
+ actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
+ }
+ if (isCurrent) {
+ ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
+ ctx.lineWidth = 2;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ } else if (actionFlash) {
+ ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
+ ctx.stroke();
+ }
+ const avS = 34;
+ drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
+ ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
+ ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = 'bold 10px ' + mono;
+ ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
+ ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
+ ctx.font = 'bold 13px ' + mono;
+ ctx.textAlign = 'right';
+ ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
+ ctx.textAlign = 'left';
+ rowY += rowH;
+ });
+ };
+
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ drawRows(order);
+ } else {
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ const rows = [{ kind: 'human', seat: 1 }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
+ }
+ drawRows(rows);
+ }
+ }
+
+ const framePad = 10;
+ const bigAv = Math.min(72, Math.max(52, ch * 0.10));
+ const avBox = bigAv + 10;
+ const integrityMinW = 120;
+ const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34)));
+ let rightX = cw - pad - rightW;
+ const topY = 8;
+ const leftReserve = towerMapHud ? (pad + 8) : (pad + boardW + 14);
+ if (rightX < leftReserve) {
+ rightX = Math.max(pad, cw - pad - rightW);
+ }
+ const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10);
+ const innerTop = topY + framePad;
+ const integrityX = rightX + framePad;
+ const integrityH = avBox + 2;
+ const avFrameX = integrityX + integrityW + 10;
+ const avFrameY = innerTop;
+ const footerH = towerMapHud ? 0 : 26;
+ const turnPanelH = framePad + integrityH + (towerMapHud ? framePad : (10 + footerH + framePad));
+
+ ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(rightX, topY, rightW, turnPanelH, 8);
+ ctx.stroke();
+
+ let curEntry = null;
+ if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
+ curEntry = order.find((e) => e.seat === currentSeat) || null;
+ }
+ const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
+
+ drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem);
+
+ const avCx = avFrameX + avBox / 2;
+ ctx.save();
+ ctx.shadowColor = 'rgba(0, 234, 255, 0.5)';
+ ctx.shadowBlur = 12;
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)';
+ ctx.lineWidth = 2;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)';
+ ctx.lineWidth = 1;
+ roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
+ ctx.stroke();
+ drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint, towerMapHud ? { headBust: true } : undefined);
+
+ if (!towerMapHud) {
+ const footY = innerTop + integrityH + 12;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)';
+ ctx.font = monoSm;
+ ctx.fillText(
+ 'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo,
+ rightX + rightW / 2,
+ footY,
+ );
+ }
+
+ const termW = Math.min(380, Math.max(220, cw * 0.42));
+ const logLineH = 12;
+ const logMaxLines = 5;
+ const logBlockH = logMaxLines * logLineH;
+ /** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */
+ const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10);
+ const termY = Math.max((towerMapHud ? 28 : boardH + 24), ch - termH - 52);
+ ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ roundRectPath(pad, termY, termW, termH, 6);
+ ctx.stroke();
+
+ ctx.fillStyle = '#c099ff';
+ ctx.font = '600 10px ' + mono;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = monoSm;
+ const logTop = termY + 24;
+ let ly = logTop;
+ const logLines = stackPreviewHudLog.length
+ ? stackPreviewHudLog.slice(-logMaxLines)
+ : ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
+ logLines.forEach((ln) => {
+ ctx.fillText(ln, pad + 10, ly);
+ ly += logLineH;
+ });
+
+ const decryptPct = stackTowerDecryptPctPlay();
+ const barPadX = 10;
+ const barH = 8;
+ const barW = termW - barPadX * 2;
+ const barY = termY + termH - barH - 10;
+ const labelY = barY - 14;
+ if (labelY >= logTop + 4) {
+ ctx.fillStyle = '#94e2d5';
+ ctx.font = monoSm;
+ ctx.textBaseline = 'top';
+ ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY);
+ }
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.fillRect(pad + barPadX, barY, barW, barH);
+ ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
+ ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH);
+ ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
+ ctx.strokeRect(pad + barPadX, barY, barW, barH);
+
+ ctx.restore();
+ }
+
+ function useCyberPlayHud() {
+ return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()
+ || isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay()));
+ }
+
+ /** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
+ function cyberHudQuizCarryPeerScore(peerId) {
+ if (peerId == null) return 0;
+ return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
+ }
+
+ /** แมป mng8a80o ช่วงเล่น: คำถามอยู่บนแผนที่ (โซนทอง) — HUD กลางเหลือ TIME + แผ่นเวลา */
+ function syncQuizQuestionMissionCyberCenterHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const qBand = document.getElementById('play-cyber-quiz-mission-q-band');
+ const qText = document.getElementById('play-cyber-quiz-mission-q-text');
+ const qPlaque = document.getElementById('play-cyber-quiz-mission-q-plaque');
+ const tb = root ? root.querySelector('.play-cyber-time-block') : null;
+ const on = isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay();
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (qBand) {
+ qBand.classList.add('is-hidden');
+ qBand.setAttribute('aria-hidden', 'true');
+ }
+ if (qText) qText.textContent = '';
+ if (qPlaque) {
+ qPlaque.classList.add('is-hidden');
+ qPlaque.removeAttribute('src');
+ delete qPlaque.dataset.qmQsrc;
+ }
+ }
+ const plaqueImg = root ? document.getElementById('play-cyber-time-plaque-img') : null;
+ const head = tb && tb.querySelector ? tb.querySelector('.play-cyber-time-head') : null;
+ const clearQmTimePlaque = () => {
+ if (plaqueImg) {
+ plaqueImg.onload = null;
+ plaqueImg.onerror = null;
+ plaqueImg.removeAttribute('src');
+ plaqueImg.classList.add('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'true');
+ delete plaqueImg.dataset.qmTimeSrc;
+ }
+ if (head) head.classList.remove('play-cyber-time-head--qm-plaque');
+ if (tb) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ delete tb.dataset.qmTimeSrc;
+ }
+ };
+ if (tb && plaqueImg && head) {
+ const tUrl = questionMissionHudAssetUrl('time.png');
+ if (on) {
+ tb.style.backgroundImage = '';
+ tb.style.backgroundSize = '';
+ tb.style.backgroundPosition = '';
+ tb.style.backgroundRepeat = '';
+ tb.style.paddingTop = '';
+ tb.style.minWidth = '';
+ const applyPlaque = () => {
+ head.classList.add('play-cyber-time-head--qm-plaque');
+ plaqueImg.classList.remove('is-hidden');
+ plaqueImg.setAttribute('aria-hidden', 'false');
+ };
+ if (plaqueImg.dataset.qmTimeSrc === tUrl && plaqueImg.getAttribute('src') === tUrl
+ && plaqueImg.complete && (plaqueImg.naturalWidth || 0) > 0) {
+ applyPlaque();
+ } else if (plaqueImg.dataset.qmTimeSrc !== tUrl || plaqueImg.getAttribute('src') !== tUrl) {
+ plaqueImg.dataset.qmTimeSrc = tUrl;
+ plaqueImg.onload = function () {
+ if ((plaqueImg.naturalWidth || 0) > 0) applyPlaque();
+ else clearQmTimePlaque();
+ };
+ plaqueImg.onerror = function () {
+ clearQmTimePlaque();
+ };
+ plaqueImg.setAttribute('src', tUrl);
+ }
+ } else {
+ clearQmTimePlaque();
+ }
+ }
+ }
+
+ function playCyberHudEsc(t) {
+ return String(t == null ? '' : t)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function resolveCyberHudAvatarTintPlay(row) {
+ if (row && row.playTint && row.playTint.head) return row.playTint;
+ const peerId = row && row.id != null ? row.id : '';
+ if (row && row.isMe) {
+ return me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me'));
+ }
+ const sid = peerId !== '' ? String(peerId) : '';
+ const ox = sid ? (others.get(peerId) || others.get(sid)) : null;
+ if (ox && ox.playTint) return ox.playTint;
+ return resolvePlayTintForPeer(sid);
+ }
+
+ function resolveRankedCharacterIdPlay(rankedRow) {
+ if (!rankedRow) return '';
+ if (rankedRow.characterId) return String(rankedRow.characterId);
+ const id = rankedRow.id;
+ if (id == null) return '';
+ const ox = others.get(id) || others.get(String(id));
+ if (ox && ox.characterId) return String(ox.characterId);
+ if (myId != null && String(id) === String(myId)) {
+ return String(me.characterId || getPlayCharacterId() || '');
+ }
+ return '';
+ }
+
+ /** กัน cache/แสดง canvas ว่าง (ทำให้ช่องอันดับ 1 หายรูป แต่ retry หยุดเพราะ naturalWidth > 0) */
+ function playCanvasHasOpaquePixels(canvas) {
+ if (!canvas || canvas.width < 2 || canvas.height < 2) return false;
+ try {
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+ for (let i = 3; i < data.length; i += 16) {
+ if (data[i] > 12) return true;
+ }
+ } catch (e) { /* ignore */ }
+ return false;
+ }
+
+ function ensureMePlayTintFromLobbyStorage() {
+ if (me.playTint && me.playTint.head && me.playTint.body) return;
+ const t = loadPlayTintFromLobbyPlayerStorage();
+ if (t) me.playTint = t;
+ }
+
+ function cyberHudScoreAvatarCacheKey(row) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ return [row.isMe ? 'me' : String(row.id), cid, tint.head, tint.hair, tint.body, 'faceV4'].join('|');
+ }
+
+ function findPlayCyberHudScoreAvatarSrcForPeer(peerId) {
+ if (peerId == null) return '';
+ const sid = String(peerId).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return '';
+ const row = ul.querySelector('.play-cyber-score-row[data-peer-id="' + sid + '"]');
+ if (!row) return '';
+ const av = row.querySelector('img.play-cyber-score-av');
+ if (!av || !av.src || av.src === defaultAvatarImg.src) return '';
+ if (!av.complete || av.naturalWidth < 8) return '';
+ return av.src;
+ }
+
+ /** crop หน้า idle ลง (down) + tint — ใช้ทั้ง HUD และหน้าสรุปผล ให้ตรงสีในเกม */
+ function composeCyberHudFacePortraitDataUrl(cid, tint) {
+ if (!cid || !tint) return '';
+ const dir = 'down';
+ const nowT = Date.now();
+ preloadPlayTintForAvatar(cid, tint);
+ ensurePlayLayerProbesAllDirections(cid);
+ const raw = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (!raw) return '';
+ let srcCanvas = null;
+ if (raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ const composeFrame = isUploadedCharAssetId(cid) ? 0 : idleAnimPhaseIndex(nowT);
+ const packIdle = tryComposePlayLayersFromFiles(raw, cid, dir, composeFrame, tint, { idle: true });
+ if (packIdle && packIdle.canvas && playCanvasHasOpaquePixels(packIdle.canvas)) {
+ srcCanvas = packIdle.canvas;
+ }
+ if (!srcCanvas) {
+ const tinted = getPlayTintedAvatarSource(raw, cid, dir, nowT, false, tint);
+ if (tinted && tinted.tagName === 'CANVAS' && tinted.width > 0 && playCanvasHasOpaquePixels(tinted)) {
+ srcCanvas = tinted;
+ }
+ }
+ }
+ /* ตัวละครที่ไม่มีไฟล์เลเยอร์สี (เช่น defaultChar) — compose คืน Image ดิบ → crop หน้าจากรูปดิบตรง ๆ กันช่องว่าง */
+ let drawSrc = srcCanvas;
+ if (!drawSrc && raw && raw.complete && raw.naturalWidth > 0 && raw.naturalHeight > 0) {
+ /* ตัวที่มีไฟล์เลเยอร์สี (tintable) แต่ composite ยังไม่พร้อม หรือ API characters ยังโหลดไม่เสร็จ →
+ คืน '' ให้ applyCyberHudScoreAvatarImgWithRetry retry รอเลเยอร์โหลด (avatar bot จะไม่ถูกรับจนกว่าจะเป็น data URL ที่ย้อมสีแล้ว)
+ อย่า fallback raw ที่ "ไม่มีสี" ไม่งั้น avatar ทุกคนเหมือนกัน = "สีไม่ขึ้น"; ใช้ raw เฉพาะตัวที่ API ยืนยันว่าไม่มีเลเยอร์ (defaultChar) */
+ if (playCharLayerApi.status !== 'done' || playCharLayerFromApi(cid) === true) return '';
+ drawSrc = raw;
+ }
+ if (!drawSrc) return '';
+ const face = document.createElement('canvas');
+ face.width = 120;
+ face.height = 120;
+ const ctx = face.getContext('2d');
+ if (!ctx) return '';
+ const sw = drawSrc.naturalWidth || drawSrc.width;
+ const sh = drawSrc.naturalHeight || drawSrc.height;
+ if (!sw || !sh) return '';
+ const sx = sw * 0.1;
+ const sy = sh * 0.02;
+ const sw2 = sw * 0.8;
+ const sh2 = sh * 0.5;
+ const cropSize = Math.min(sw2, sh2);
+ const sxCenter = sx + (sw2 - cropSize) * 0.5;
+ ctx.clearRect(0, 0, 120, 120);
+ try {
+ /* crop สี่เหลี่ยมจัตุรัสจากหัว → 120×120 ไม่ยืด */
+ ctx.drawImage(drawSrc, sxCenter, sy, cropSize, cropSize, 0, 0, 120, 120);
+ } catch (e) {
+ return '';
+ }
+ if (!playCanvasHasOpaquePixels(face)) return '';
+ try {
+ return face.toDataURL('image/png');
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function cyberHudScoreAvatarImgLooksReady(img) {
+ return !!(img && img.src && img.src !== defaultAvatarImg.src && img.complete && img.naturalWidth > 8 && img.naturalHeight > 8);
+ }
+
+ /** crop หัว (จัตุรัส) จาก src เต็มตัว (lobby idle — ย้อมสีถูกแล้ว) → 120×120 data URL (async, cache)
+ * ใช้เมื่อ composeCyberHudFacePortraitDataUrl คืน '' (เช่นตัวที่ layer ไม่ compose) → human เป็น "หน้า" เหมือนบอท ไม่ใช่เต็มตัว */
+ var cyberHudLobbyFaceCropCache = new Map();
+ function cyberHudLobbyIdleHeadFaceDataUrl(src) {
+ if (!src) return '';
+ if (src.indexOf('data:') === 0 && src.length < 200) return '';
+ if (cyberHudLobbyFaceCropCache.has(src)) return cyberHudLobbyFaceCropCache.get(src);
+ cyberHudLobbyFaceCropCache.set(src, '');
+ var im = new Image();
+ im.onload = function () {
+ try {
+ var sw = im.naturalWidth, sh = im.naturalHeight;
+ if (!sw || !sh) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var c = document.createElement('canvas');
+ c.width = 120; c.height = 120;
+ var ctx = c.getContext('2d');
+ if (!ctx) { cyberHudLobbyFaceCropCache.set(src, ''); return; }
+ var cw = sw * 0.8, ch = sh * 0.5, cs = Math.min(cw, ch);
+ var sxC = sw * 0.1 + (cw - cs) * 0.5, syC = sh * 0.02;
+ ctx.clearRect(0, 0, 120, 120);
+ ctx.drawImage(im, sxC, syC, cs, cs, 0, 0, 120, 120);
+ cyberHudLobbyFaceCropCache.set(src, playCanvasHasOpaquePixels(c) ? c.toDataURL('image/png') : '');
+ } catch (e) { cyberHudLobbyFaceCropCache.set(src, ''); }
+ };
+ im.onerror = function () { cyberHudLobbyFaceCropCache.set(src, ''); };
+ im.src = src;
+ return '';
+ }
+
+ /** โปรไฟล์มุมขวาบน — mission mock ใช้หน้า crop สี่เหลี่ยม ไม่ดึง sprite เต็มตัว */
+ function setCyberHudProfilePortraitImg(portraitImg) {
+ if (!portraitImg) return;
+ const cid = me.characterId || getPlayCharacterId();
+ if (!cid) {
+ portraitImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const row = {
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ };
+ if (isMissionMockHudPlay()) {
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ const cacheKey = cyberHudScoreAvatarCacheKey(row) + '|profile';
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ portraitImg.src = cached;
+ return;
+ }
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ portraitImg.src = faceUrl;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ }
+ setCyberHudScoreAvatarImg(portraitImg, row);
+ }
+
+ function applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt) {
+ attempt = attempt || 0;
+ setCyberHudProfilePortraitImg(portraitImg);
+ if (cyberHudScoreAvatarImgLooksReady(portraitImg)) return;
+ if (attempt >= 16) return;
+ const cid = me.characterId || getPlayCharacterId();
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) {
+ preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay({
+ id: myId,
+ characterId: cid,
+ isMe: true,
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')),
+ }));
+ }
+ applyCyberHudProfilePortraitImgWithRetry(portraitImg, attempt + 1);
+ }, delay);
+ }
+
+ /** แถบ Cyber SCORE + สรุปผล — หน้า idle down + tint ต่อผู้เล่น */
+ function setCyberHudScoreAvatarImg(avImg, row, skipReuse) {
+ const cid = row.characterId ? String(row.characterId) : '';
+ if (!cid) {
+ avImg.src = defaultAvatarImg.src;
+ return;
+ }
+ const cacheKey = cyberHudScoreAvatarCacheKey(row);
+ const cached = cyberHudScoreAvatarUrlCache.get(cacheKey);
+ if (cached) {
+ avImg.src = cached;
+ return;
+ }
+ /* skipReuse: ตอน retry (เช่น crop หัวยังไม่เสร็จ) อย่าอ่าน src ปัจจุบันจาก DOM กลับมา
+ ไม่งั้นจะได้ "รูปเต็มตัวชั่วคราว" กลับมาแล้ว cache ค้าง (human โชว์เต็มตัวคนเดียว) */
+ const hudSrc = skipReuse ? '' : findPlayCyberHudScoreAvatarSrcForPeer(row.id);
+ if (hudSrc) {
+ avImg.src = hudSrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, hudSrc);
+ return;
+ }
+ const tint = resolveCyberHudAvatarTintPlay(row);
+ /* ย้อมสีตาม tint ปัจจุบันก่อนเสมอ (รวมตัวเรา) — อย่าใช้ lobby idle ต่อ characterId เพราะสีไม่ตรงที่เลือกใน lobby */
+ const faceUrl = composeCyberHudFacePortraitDataUrl(cid, tint);
+ if (faceUrl) {
+ avImg.src = faceUrl;
+ if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) {
+ const first = cyberHudScoreAvatarUrlCache.keys().next();
+ if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value);
+ }
+ cyberHudScoreAvatarUrlCache.set(cacheKey, faceUrl);
+ return;
+ }
+ if (row.isMe) {
+ const lobbySrc = playLobbyIdleAvatarSrcForCharacter(cid);
+ if (lobbySrc) {
+ if (isMissionMockHudPlay()) {
+ /* mock hud (mg2 ฯลฯ): crop หัวจาก lobby idle → "หน้า" จัตุรัสเหมือนบอท (กัน human โชว์เต็มตัวคนเดียว) */
+ const lobbyFace = cyberHudLobbyIdleHeadFaceDataUrl(lobbySrc);
+ if (lobbyFace) {
+ avImg.src = lobbyFace;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbyFace);
+ return;
+ }
+ /* crop ยังไม่เสร็จ (async) → ใช้ "หน้า default" ชั่วคราว (อย่าโชว์เต็มตัว — ผู้เล่นต้องเห็นแค่หัวเหมือนบอท) แล้ว retry จนได้หัวจริง
+ จำกัดจำนวน retry กัน loop ถ้า crop ล้มเหลวถาวร */
+ avImg.src = defaultAvatarImg.src;
+ var hudAvTries = avImg.__hudAvHeadTries || 0;
+ if (hudAvTries < 12) {
+ avImg.__hudAvHeadTries = hudAvTries + 1;
+ setTimeout(function () {
+ if (avImg.isConnected) setCyberHudScoreAvatarImg(avImg, row, true);
+ }, 200);
+ }
+ return;
+ }
+ avImg.src = lobbySrc;
+ cyberHudScoreAvatarUrlCache.set(cacheKey, lobbySrc);
+ return;
+ }
+ }
+ const dir = 'down';
+ const nowT = Date.now();
+ const frameIm = getCharacterFrame(cid, dir, nowT, false) || getCharacterImg(cid, dir);
+ if (frameIm && frameIm.src) {
+ if (!frameIm.complete || !frameIm.naturalWidth) {
+ avImg.src = frameIm.src;
+ frameIm.addEventListener('load', function onHudAvFrame() {
+ frameIm.removeEventListener('load', onHudAvFrame);
+ setCyberHudScoreAvatarImg(avImg, row);
+ }, { once: true });
+ return;
+ }
+ }
+ avImg.src = defaultAvatarImg.src;
+ }
+
+ /** ลองโหลด avatar ซ้ำจน sprite/tint พร้อม (กันรูปหายตอนเปิด overlay) */
+ function applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt) {
+ attempt = attempt || 0;
+ setCyberHudScoreAvatarImg(avImg, row);
+ /* mock hud: ต้องได้ "หน้า" 120×120 (naturalWidth≥100) เท่านั้นถึงหยุด — กันค้างที่ raw frame 64×64 (เต็มตัว)
+ หรือ /img/ sprite เต็มตัว ก่อน composeCyberHudFacePortraitDataUrl จะได้หน้า (ตอน char-layer API ยังโหลด) */
+ const mockFaceOk = !isMissionMockHudPlay() || (avImg.naturalWidth >= 100 && avImg.naturalHeight >= 100);
+ const ok = cyberHudScoreAvatarImgLooksReady(avImg) && mockFaceOk
+ && (avImg.src.indexOf('data:image/') === 0 || (row.isMe && !isMissionMockHudPlay() && avImg.src.indexOf('/img/') >= 0));
+ if (ok) return;
+ if (attempt >= 16) return;
+ const cid = row.characterId ? String(row.characterId) : '';
+ const delay = 40 + attempt * 90;
+ setTimeout(function () {
+ if (cid) preloadPlayTintForAvatar(cid, resolveCyberHudAvatarTintPlay(row));
+ applyCyberHudScoreAvatarImgWithRetry(avImg, row, attempt + 1);
+ }, delay);
+ }
+
+ function refreshGcmTfMockAvatarsPlay(rowEl, ranked) {
+ if (!rowEl || !Array.isArray(ranked)) return;
+ const imgs = rowEl.querySelectorAll('img.gcm-av');
+ ranked.forEach(function (r, idx) {
+ const img = imgs[idx];
+ if (!img) return;
+ const cid = resolveRankedCharacterIdPlay(r);
+ const avRow = {
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ playTint: resolveCyberHudAvatarTintPlay({
+ id: r.id,
+ characterId: cid,
+ isMe: myId != null && r.id != null && String(r.id) === String(myId),
+ }),
+ };
+ applyCyberHudScoreAvatarImgWithRetry(img, avRow, 0);
+ });
+ }
+
+ function quizQuestionMissionJoystickResetVisual() {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (knob) knob.style.transform = 'translate(-50%, -50%)';
+ }
+
+ function quizQuestionMissionJoystickUpdateFromClientXY(clientX, clientY) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const knob = document.getElementById('quiz-q-mission-joystick-knob');
+ if (!base || !knob) return;
+ const r = base.getBoundingClientRect();
+ const cx = r.left + r.width * 0.5;
+ const cy = r.top + r.height * 0.5;
+ let dx = clientX - cx;
+ let dy = clientY - cy;
+ const half = Math.min(r.width, r.height) * 0.5;
+ const maxKn = Math.max(18, half * 0.52);
+ const len = Math.hypot(dx, dy);
+ if (len > maxKn && len > 1e-6) {
+ dx = (dx / len) * maxKn;
+ dy = (dy / len) * maxKn;
+ }
+ const nlen = Math.hypot(dx, dy);
+ const dead = maxKn * 0.12;
+ if (nlen < dead) {
+ quizQuestionMissionJoyVecX = 0;
+ quizQuestionMissionJoyVecY = 0;
+ knob.style.transform = 'translate(-50%, -50%)';
+ return;
+ }
+ quizQuestionMissionJoyVecX = dx / maxKn;
+ quizQuestionMissionJoyVecY = dy / maxKn;
+ knob.style.transform = 'translate(calc(-50% + ' + dx + 'px), calc(-50% + ' + dy + 'px))';
+ }
+
+ function syncQuizQuestionMissionJoystickPlay() {
+ const root = document.getElementById('quiz-question-mission-joystick');
+ if (!root) return;
+ const jumperOn = isJumpSurviveMissionHudActivePlay();
+ const on = !!((isQuizQuestionMissionHudActivePlay() || jumperOn) && !isChatFocused());
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (on) {
+ /* สลับรูป joystick ตามฉาก: jumper → /img/Jumper/, quiz คำถาม → /img/QUESTION/ (เปลี่ยน src เฉพาะตอน skin เปลี่ยน) */
+ const skin = jumperOn ? 'jumper' : 'question';
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (bg && bg.dataset.joySkin !== skin) {
+ bg.src = jumperOn ? jumperAssetUrl('btn-joystick-1.png') : questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = skin;
+ }
+ if (knobImg && knobImg.dataset.joySkin !== skin) {
+ knobImg.src = jumperOn ? jumperAssetUrl('btn-joystick-2.png') : questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = skin;
+ }
+ }
+ if (!on) {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ if (base && quizQuestionMissionJoyPointerId != null) {
+ try {
+ base.releasePointerCapture(quizQuestionMissionJoyPointerId);
+ } catch (_e) { /* ignore */ }
+ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ }
+
+ /* mng8a80o: scale HUD ทั้งก้อน (fix 1920×1080) ให้พอดี container แบบรักษา 16:9 — เทียบ scale.js ของ mockup Minigame-1
+ สำคัญ: คำนวณเฉพาะตอนเข้าโหมด/resize เท่านั้น (ห้ามอ่าน layout ทุกเฟรมใน draw loop → กระตุก) */
+ let _quizTfScaleLast = 0;
+ let _quizTfMockActivePrev = false;
+ function updateQuizTfMockScale(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root || !root.classList.contains('play-cyber-hud--quiz-tf-mock')) return;
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ root.style.position = 'fixed';
+ root.style.left = Math.round(r.left) + 'px';
+ root.style.top = Math.round(r.top) + 'px';
+ root.style.width = Math.round(cw) + 'px';
+ root.style.height = Math.round(ch) + 'px';
+ root.style.right = 'auto';
+ root.style.bottom = 'auto';
+ root.style.transform = 'none';
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ if (Math.abs(s - _quizTfScaleLast) < 0.0005
+ && root.dataset.qtfStackW === String(Math.round(cw))
+ && root.dataset.qtfStackH === String(Math.round(ch))) {
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ return;
+ }
+ _quizTfScaleLast = s;
+ root.dataset.qtfStackW = String(Math.round(cw));
+ root.dataset.qtfStackH = String(Math.round(ch));
+ root.style.setProperty('--qtf-scale', String(s));
+ syncMg2MockJumpButtonBoundsPlay(r, s);
+ }
+
+ function syncMg2MockJumpButtonBoundsPlay(stackRect, scale) {
+ const jumpBtn = document.getElementById('gauntlet-crown-jump-btn');
+ const hud = document.getElementById('play-cyber-hud');
+ const _jbSkin = hud ? hud.dataset.missionMockSkin : '';
+ if (!jumpBtn || !hud || (_jbSkin !== 'gauntlet_crown' && _jbSkin !== 'jumper')) {
+ if (jumpBtn) {
+ jumpBtn.style.removeProperty('left');
+ jumpBtn.style.removeProperty('top');
+ jumpBtn.style.removeProperty('width');
+ jumpBtn.style.removeProperty('height');
+ jumpBtn.style.removeProperty('right');
+ jumpBtn.style.removeProperty('bottom');
+ jumpBtn.style.removeProperty('transform');
+ jumpBtn.style.removeProperty('filter');
+ }
+ return;
+ }
+ if (jumpBtn.classList.contains('is-hidden')) return;
+ const r = stackRect || (document.getElementById('play-canvas-stack') || {}).getBoundingClientRect?.();
+ const s = Number.isFinite(scale) ? scale : _quizTfScaleLast;
+ if (!r || !(s > 0)) return;
+ jumpBtn.style.position = 'fixed';
+ jumpBtn.style.left = Math.round(r.left + 1648 * s) + 'px';
+ jumpBtn.style.top = Math.round(r.top + 818 * s) + 'px';
+ jumpBtn.style.width = Math.round(231 * s) + 'px';
+ jumpBtn.style.height = Math.round(231 * s) + 'px';
+ jumpBtn.style.right = 'auto';
+ jumpBtn.style.bottom = 'auto';
+ jumpBtn.style.transform = 'none';
+ jumpBtn.style.filter = 'drop-shadow(0 0 ' + Math.max(8, Math.round(18 * s)) + 'px rgba(255, 110, 220, 0.45))';
+ }
+
+ function clearQuizTfMockHudBoundsPlay(root) {
+ root = root || document.getElementById('play-cyber-hud');
+ if (!root) return;
+ const stage = root.querySelector('.play-cyber-hud-mock-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'true');
+ root.style.removeProperty('left');
+ root.style.removeProperty('top');
+ root.style.removeProperty('width');
+ root.style.removeProperty('height');
+ root.style.removeProperty('right');
+ root.style.removeProperty('bottom');
+ root.style.removeProperty('transform');
+ delete root.dataset.qtfStackW;
+ delete root.dataset.qtfStackH;
+ syncMg2MockJumpButtonBoundsPlay(null, 0);
+ }
+ /* mng8a80o: สรุปผลภารกิจ (06-summary) — scale popup 1451×851 ตามสูตรเดียวกับ HUD (min vw/1920, vh/1080) */
+ function missionMockOverlayScalePlay() {
+ const stack = document.getElementById('play-canvas-stack');
+ const r = stack && stack.getBoundingClientRect ? stack.getBoundingClientRect() : null;
+ const cw = Math.max(1, r && r.width ? r.width : window.innerWidth);
+ const ch = Math.max(1, r && r.height ? r.height : window.innerHeight);
+ return Math.min(cw / 1920, ch / 1080);
+ }
+ function updateGcmTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-mission-overlay');
+ if (!ov) return;
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gcm-tf-scale', String(s));
+ }
+ /* popup HOW TO PLAY (01-howto) — scale stage 1920×1080 · MG2 ยึด #play-canvas-stack เหมือน HUD */
+ function updateGchTfMockScale(ov) {
+ ov = ov || document.getElementById('gauntlet-crown-howto-overlay');
+ if (!ov) return;
+ const mg2 = ov.classList.contains('gch-mg2-mock');
+ if (mg2) {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack) return;
+ const r = stack.getBoundingClientRect();
+ const cw = Math.max(1, r.width || stack.clientWidth || 0);
+ const ch = Math.max(1, r.height || stack.clientHeight || 0);
+ const s = Math.min(cw / 1920, ch / 1080);
+ if (!(s > 0 && isFinite(s))) return;
+ ov.style.position = 'fixed';
+ ov.style.left = Math.round(r.left) + 'px';
+ ov.style.top = Math.round(r.top) + 'px';
+ ov.style.width = Math.round(cw) + 'px';
+ ov.style.height = Math.round(ch) + 'px';
+ ov.style.right = 'auto';
+ ov.style.bottom = 'auto';
+ ov.style.transform = 'none';
+ ov.style.setProperty('--gch-tf-scale', String(s));
+ const stage = ov.querySelector('.gch-mg2-stage');
+ if (stage) stage.setAttribute('aria-hidden', 'false');
+ return;
+ }
+ clearGchMg2OverlayBoundsPlay(ov);
+ const s = missionMockOverlayScalePlay();
+ if (s > 0 && isFinite(s)) ov.style.setProperty('--gch-tf-scale', String(s));
+ }
+ let _quizTfScaleObserversBound = false;
+ function ensureQuizTfScaleResizeListener() {
+ if (_quizTfScaleObserversBound) return;
+ _quizTfScaleObserversBound = true;
+ const recompute = function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ const ov = document.getElementById('gauntlet-crown-mission-overlay');
+ if (ov && ov.classList.contains('gcm-quiz-tf-mock') && !ov.classList.contains('is-hidden')) updateGcmTfMockScale(ov);
+ const hv = document.getElementById('gauntlet-crown-howto-overlay');
+ if (hv && hv.classList.contains('gch-quiz-tf-mock') && !hv.classList.contains('is-hidden')) updateGchTfMockScale(hv);
+ };
+ window.addEventListener('resize', recompute);
+ window.addEventListener('orientationchange', recompute);
+ /* ResizeObserver จับ container ที่เปลี่ยนขนาดโดยไม่มี window resize (เช่น ลากแบ่งจอ editor) */
+ if (typeof ResizeObserver === 'function') {
+ try {
+ const ro = new ResizeObserver(recompute);
+ const host = document.querySelector('.game-wrap') || document.documentElement;
+ const stack = document.getElementById('play-canvas-stack');
+ if (host) ro.observe(host);
+ if (stack) ro.observe(stack);
+ } catch (e) { /* ignore */ }
+ }
+ }
+
+ function syncPlayCyberHud() {
+ const root = document.getElementById('play-cyber-hud');
+ const stackEl = document.getElementById('play-canvas-stack');
+ const on = useCyberPlayHud();
+ if (root) {
+ root.classList.toggle('is-hidden', !on);
+ root.setAttribute('aria-hidden', on ? 'false' : 'true');
+ if (!on) {
+ root.classList.remove('play-cyber-hud--question-mission');
+ root.classList.remove('play-cyber-hud--score-flush-left');
+ root.classList.remove('play-cyber-hud--jump-mission-hide-score');
+ root.classList.remove('play-cyber-hud--ss-self-integrity-left');
+ root.classList.remove('play-cyber-hud--last-light-no-time');
+ root.classList.remove('play-cyber-hud--quiz-tf-mock');
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ _quizTfMockActivePrev = false;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ }
+ if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on);
+ const gw = document.querySelector('.game-wrap');
+ if (gw) gw.classList.toggle('play-cyber-active', !!on);
+ if (!on || !root) return;
+ ensureMePlayTintFromLobbyStorage();
+ root.classList.toggle(
+ 'play-cyber-hud--question-mission',
+ !!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
+ || isSpaceShooterMissionHudActivePlay()),
+ );
+ /* Minigame 1–7 mission — HUD fix 1920×1080 ตาม mock (score-bar / profile qb-room) */
+ const missionMockOn = !!isMissionMockHudPlay();
+ root.classList.toggle('play-cyber-hud--quiz-tf-mock', missionMockOn);
+ document.documentElement.classList.toggle('play-mission-mock-hud', missionMockOn && on);
+ document.documentElement.classList.toggle('play-mg2-mock-hud', missionMockOn && on && getMissionMockHudSkinPlay() === 'gauntlet_crown');
+ applyMissionMockHudCssVarsPlay(root);
+ const tfMockOn = root.classList.contains('play-cyber-hud--quiz-tf-mock');
+ if (tfMockOn) {
+ ensureQuizTfScaleResizeListener();
+ updateQuizTfMockScale(root);
+ if (!_quizTfMockActivePrev) {
+ requestAnimationFrame(function () {
+ const r = document.getElementById('play-cyber-hud');
+ if (r && r.classList.contains('play-cyber-hud--quiz-tf-mock')) updateQuizTfMockScale(r);
+ });
+ }
+ } else if (_quizTfMockActivePrev) {
+ clearQuizTfMockHudBoundsPlay(root);
+ root.style.removeProperty('--qtf-scale');
+ _quizTfScaleLast = 0;
+ document.documentElement.classList.remove('play-mission-mock-hud');
+ document.documentElement.classList.remove('play-mg2-mock-hud');
+ }
+ _quizTfMockActivePrev = tfMockOn;
+ root.classList.toggle(
+ 'play-cyber-hud--stack-tower-canvas-hud',
+ !!(playBotsEnabled() && stackMini && isStack() && isStackTowerMissionUiMapPlay()),
+ );
+ root.classList.toggle('play-cyber-hud--ss-self-integrity-left', !!isSpaceShooterMissionHudActivePlay());
+ /** แถบ SCORE แนวนอน (mno9kb07) — ใช้รูปแบบเดียวกับ quiz_carry / พรีวิว editor */
+ root.classList.toggle(
+ 'play-cyber-hud--gauntlet-crown-strip',
+ !!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry())
+ && !(missionMockOn && getMissionMockHudSkinPlay() === 'gauntlet_crown')),
+ );
+ root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry()));
+ root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay());
+ /** Last Light mno9kb07: ไม่แสดง TIME / ซูมใต้เวลา — จบด้วยรันเวย์ ไม่ใช้นาฬิกา */
+ root.classList.toggle('play-cyber-hud--last-light-no-time', !!(on && isGauntletCrownHeistMapPlay()));
+ const timeLabelEl = root.querySelector('.play-cyber-time-label');
+ if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : '';
+ const timeVal = document.getElementById('play-cyber-time-val');
+ const timeSub = document.getElementById('play-cyber-time-sub');
+ const portrait = document.getElementById('play-cyber-portrait-img');
+ const statusEl = document.getElementById('play-cyber-self-status');
+ const hintEl = document.getElementById('play-cyber-hint');
+ const previewEl = document.getElementById('play-cyber-preview-line');
+
+ if (timeSub) {
+ const bbMegaLiveHud = isBalloonBoss() && isMegaVirusMissionShellMapPlay() && !isGauntletCrownPregameBlockingPlay();
+ timeSub.textContent = isQuizQuestionMissionHudActivePlay()
+ ? (playQuizPhaseLocal === 'read' ? 'READ · อ่านคำถาม'
+ : (playQuizPhaseLocal === 'answer' ? 'ANSWER · เดินไปโซน จริง / เท็จ'
+ : 'QUIZ MISSION · ประลองความรู้'))
+ : (isSpaceShooterMissionHudActivePlay()
+ ? 'SPACE SHOOTER · ARCADE — TIME (sec) · ภารกิจยิงยาน'
+ : (isStackTowerMissionHudActivePlay()
+ ? 'TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส'
+ : (isQuizCarry()
+ ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)'
+ : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
+ : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
+ : (isBalloonBoss() ? (bbMegaLiveHud ? '' : 'BALLOON BOSS · MEGA VIRUS')
+ : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))))));
+ timeSub.style.display = bbMegaLiveHud ? 'none' : '';
+ }
+ const zoomHintEl = document.getElementById('play-cyber-embed-zoom-hint');
+ if (zoomHintEl) {
+ const showEmbedZoom = !!(previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()
+ && !(isBalloonBoss() && isMegaVirusMissionShellMapPlay()));
+ if (showEmbedZoom) {
+ const zNum = Number(playEmbedUserZoomMul.toFixed(2));
+ zoomHintEl.textContent = '×' + String(zNum);
+ zoomHintEl.classList.remove('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'false');
+ } else {
+ zoomHintEl.textContent = '';
+ zoomHintEl.classList.add('is-hidden');
+ zoomHintEl.setAttribute('aria-hidden', 'true');
+ }
+ }
+ if (timeVal) {
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'live' || quizQuestionMissionPhase === 'ended') {
+ timeVal.textContent = '···';
+ } else if (!playQuizPhaseEndsAt) {
+ timeVal.textContent = '—';
+ } else {
+ timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)));
+ }
+ } else if (isStack() && isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase === 'live') {
+ const remSt = Math.max(0, Math.ceil(stackTowerMissionTimeLimitSecPlay() - stackTowerMissionElapsedSecPlay()));
+ timeVal.textContent = String(remSt);
+ } else if (stackTowerMissionPhase === 'howto' || stackTowerMissionPhase === 'countdown') {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isQuizCarry()) {
+ if (quizCarrySessionEnded) {
+ timeVal.textContent = '0';
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedCountdownEndAt > Date.now()) {
+ const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem);
+ } else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) {
+ const rem2 = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - Date.now()) / 1000));
+ timeVal.textContent = String(rem2);
+ } else if (quizCarryPregameActive) {
+ timeVal.textContent = '···';
+ } else if (!quizCarryCurrent) {
+ timeVal.textContent = '—';
+ } else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000)));
+ } else if (quizCarryAnswerCloseAt > Date.now()) {
+ timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000)));
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else if (isGauntlet()) {
+ if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ timeVal.textContent = '···';
+ } else {
+ timeVal.textContent = '∞';
+ }
+ } else if (isSpaceShooter()) {
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) {
+ const limSs = spaceShooterTimeLimitSecPlay();
+ if (spaceShooterMissionPhase === 'howto' || spaceShooterMissionPhase === 'countdown') {
+ timeVal.textContent = String(limSs > 0 ? limSs : 0);
+ } else {
+ timeVal.textContent = '0';
+ }
+ } else {
+ const remSs = spaceShooterRemainingSecPlay();
+ timeVal.textContent = remSs != null ? String(remSs) : '∞';
+ }
+ } else if (spaceShooterGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = spaceShooterRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isBalloonBoss()) {
+ if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) {
+ timeVal.textContent = '···';
+ } else if (balloonBossGameEnded) {
+ timeVal.textContent = '0';
+ } else {
+ const rem = balloonBossRemainingSecPlay();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) {
+ timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0');
+ } else {
+ const rem = jumpSurviveRemainingSecMission();
+ timeVal.textContent = rem != null ? String(rem) : '∞';
+ }
+ } else {
+ const t = jumpSurviveSessionStartMs > 0
+ ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
+ : 0;
+ timeVal.textContent = String(t);
+ }
+ }
+
+ if (portrait) {
+ applyCyberHudProfilePortraitImgWithRetry(portrait, 0);
+ }
+ if (statusEl) {
+ if (isSpaceShooterMissionHudActivePlay()) {
+ /* MG6 life ย้ายไป overlay เฉพาะ #mg6-life-hud (syncSpaceShooterLifeDomHudPlay) — คุมตำแหน่ง/ขนาดเท่า MG3 */
+ statusEl.textContent = '';
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ statusEl.textContent = 'QUIZ LINK · ประลองความรู้';
+ } else if (isStackTowerMissionHudActivePlay()) {
+ statusEl.textContent = 'DECRYPT UPLINK · TOWER';
+ } else if (isJumpSurviveMissionHudActivePlay()) {
+ statusEl.textContent = 'JUMPER LINK · SURVIVE';
+ } else if (isQuizCarry() && quizCarrySessionEnded) {
+ statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม';
+ } else if (isJumpSurvive() && jumpSurviveEliminated) {
+ statusEl.textContent = 'SPECTATOR · LINK SEVERED';
+ } else if (isBalloonBoss() && me.balloonBossEliminated) {
+ statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
+ } else if (isGauntletCrownHeistMapPlay()) {
+ statusEl.textContent = '';
+ } else {
+ statusEl.textContent = 'OPERATOR ONLINE';
+ }
+ }
+ if (hintEl) {
+ if (isQuizQuestionMissionHudActivePlay()) {
+ hintEl.textContent = '';
+ hintEl.setAttribute('aria-hidden', 'true');
+ } else {
+ hintEl.removeAttribute('aria-hidden');
+ if (isStackTowerMissionHudActivePlay()) {
+ hintEl.textContent = 'กด SPACE / ENTER หรือคลิกที่จอ = DROP · วางต่อเนื่องแม่น = โบนัส · ถอดรหัสให้ครบ 100% ภายในเวลา';
+ } else if (isQuizCarry()) {
+ hintEl.textContent = quizCarrySessionEnded
+ ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
+ : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
+ } else if (isJumpSurvive()) {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)'
+ : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน';
+ } else {
+ hintEl.textContent = jumpSurviveEliminated
+ ? 'Spectate remaining nodes · Exit room to reset session'
+ : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
+ }
+ } else if (isSpaceShooter()) {
+ hintEl.textContent = spaceShooterGameEnded
+ ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
+ : 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
+ } else if (isBalloonBoss()) {
+ hintEl.textContent = balloonBossGameEnded
+ ? 'จบแล้ว · Session ended — see overlay'
+ : (me.balloonBossEliminated
+ ? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
+ : (isMegaVirusMissionShellMapPlay()
+ ? 'ลูกศรบนวงหมุนเอง — ยิงตามมุมลูกศร ณ ตอนกด Space · Ring spins · Space = fire along arrow'
+ : 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ'));
+ } else if (isGauntletCrownHeistMapPlay()) {
+ hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end';
+ } else {
+ hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
+ }
+ }
+ }
+ if (previewEl) {
+ if (playBotsEnabled() && mapData) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ const botTarget = playBotTargetHeadcount();
+ const simLabel = detectiveCaseFillBots ? 'CASE' : 'SIM';
+ previewEl.textContent = `${simLabel} · humans ${human} + bots ${bots} · target ${botTarget}`;
+ previewEl.classList.remove('is-hidden');
+ } else {
+ previewEl.textContent = '';
+ previewEl.classList.add('is-hidden');
+ }
+ }
+
+ syncQuizQuestionMissionCyberCenterHud();
+
+ const ul = document.getElementById('play-cyber-score-list');
+ if (!ul) return;
+ /* mnptfts2 — ไม่แสดงแผง SCORE (เหลือ TIME + โปรไฟล์) */
+ if (isJumpSurviveMissionUiMapPlay()) {
+ ul.innerHTML = '';
+ ul.dataset.cyberSig = '';
+ const boardSkip = ul.closest('.play-cyber-scoreboard');
+ if (boardSkip) boardSkip.classList.remove('play-cyber-scoreboard--crown-strip');
+ ul.classList.remove('play-cyber-score-list--crown-strip');
+ return;
+ }
+ const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry();
+ const mg2MockHud = isMissionMockHudPlay() && getMissionMockHudSkinPlay() === 'gauntlet_crown';
+ const board = ul.closest('.play-cyber-scoreboard');
+ if (board) {
+ board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud && !mg2MockHud);
+ board.classList.toggle('play-cyber-scoreboard--mg2-mock', !!mg2MockHud);
+ board.classList.toggle('mg2-score-bar', !!mg2MockHud);
+ }
+ ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud && !mg2MockHud);
+ ul.classList.toggle('play-cyber-score-list--mg2-mock', !!mg2MockHud);
+ const panelTitle = root.querySelector('.play-cyber-panel-title');
+ const crownHead = document.getElementById('play-cyber-crown-score-head');
+ const crownImg = document.getElementById('play-cyber-crown-score-img');
+ if (panelTitle && crownHead && crownImg) {
+ if (mg2MockHud) {
+ const scoreSrc = missionMockHudAssetUrl('score.png');
+ crownHead.classList.remove('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'false');
+ if (crownImg.getAttribute('src') !== scoreSrc) crownImg.setAttribute('src', scoreSrc);
+ crownImg.alt = 'SCORE :';
+ panelTitle.classList.add('is-hidden');
+ } else if (stripScoreHud) {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE :';
+ panelTitle.classList.remove('is-hidden');
+ } else {
+ crownHead.classList.add('is-hidden');
+ crownHead.setAttribute('aria-hidden', 'true');
+ crownImg.removeAttribute('src');
+ delete crownImg.dataset.gauntletScoreSrc;
+ crownImg.alt = '';
+ crownImg.onerror = null;
+ crownImg.onload = null;
+ panelTitle.textContent = 'SCORE';
+ panelTitle.classList.remove('is-hidden');
+ }
+ }
+ const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay();
+ const quizMissionHud = isQuizQuestionMissionHudActivePlay();
+ const stackMissionHud = isStackTowerMissionHudActivePlay();
+ const jumpMissionLiveHud = isJumpSurviveMissionHudActivePlay();
+ const spaceMissionLiveHud = isSpaceShooterMissionHudActivePlay();
+ /** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp */
+ const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud;
+ /** mission mock HUD — layout แถว: [avatar | ชื่อบน/คะแนนล่าง] (meta column) */
+ const tfMockHud = isMissionMockHudPlay();
+ const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
+ const rows = [];
+ rows.push({
+ id: myId,
+ nickname: me.nickname || nick,
+ characterId: me.characterId || getPlayCharacterId(),
+ playTint: me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : (nick || 'local'))),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(myId)
+ : (stackMissionHud
+ ? (playBotsEnabled() ? (me.stackPreviewHumanPts || 0) : stackTeamPts)
+ : (isGauntlet() ? (me.gauntletScore || 0)
+ : (isSpaceShooter() ? (me.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (me.balloonBossScore || 0)
+ : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(me.y),
+ eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated),
+ isMe: true,
+ });
+ others.forEach((o, id) => {
+ rows.push({
+ id,
+ nickname: o.nickname || id.slice(0, 8),
+ characterId: o.characterId,
+ playTint: o.playTint || resolvePlayTintForPeer(id),
+ score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(id)
+ : (stackMissionHud
+ ? (playBotsEnabled() && isPreviewBotId(id) ? (o.stackBotScore || 0) : stackTeamPts)
+ : (isGauntlet() ? (o.gauntletScore || 0)
+ : (isSpaceShooter() ? (o.spaceShooterScore || 0)
+ : (isBalloonBoss() ? (o.balloonBossScore || 0)
+ : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0))))),
+ y: safeYv(o.y),
+ eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
+ || !!(isBalloonBoss() && o.balloonBossEliminated)
+ || !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated),
+ isMe: false,
+ });
+ });
+ let leaderId = null;
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ let mx = -1;
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].score > mx) {
+ mx = rows[i].score;
+ leaderId = rows[i].id;
+ }
+ }
+ } else {
+ let best = Infinity;
+ for (let i = 0; i < rows.length; i++) {
+ if (!rows[i].eliminated && rows[i].y < best) {
+ best = rows[i].y;
+ leaderId = rows[i].id;
+ }
+ }
+ }
+ rows.sort((a, b) => {
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ if (b.score !== a.score) return b.score - a.score;
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th');
+ }
+ if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
+ return a.y - b.y;
+ });
+
+ /* Minigame-2 รองรับสูงสุด 6 ผู้เล่น (GAUNTLET_MAX_PLAYERS) — แถบคะแนนต้องโชว์ครบ 6 รวมตัวเรา
+ (เดิม cap 5 ตาม mockup ที่วาดตัวอย่าง 5 คน ทำให้ตัวเราคะแนนต่ำสุดถูกตัดออก) */
+ const cap = mg2MockHud ? 6 : (stripScoreHud ? (isQuizCarry() ? 8 : 6) : 8);
+ let hudRows = rows.slice(0, Math.min(cap, rows.length));
+ if (stripScoreHud) {
+ const mi = hudRows.findIndex((row) => row.isMe);
+ if (mi >= 0) {
+ const mine = hudRows.splice(mi, 1)[0];
+ hudRows.push(mine);
+ }
+ }
+ /* กันกระตุก: rebuild DOM เฉพาะตอนเนื้อหา (id/score/order/สี/ตกรอบ) เปลี่ยน — ไม่ลบ-สร้างทุกเฟรม */
+ const listSig = [
+ cyberQmMockHud ? 'qm' : '', mg2MockHud ? 'mg2' : '', stripScoreHud ? 'strip' : '', leaderId == null ? '' : String(leaderId),
+ hudRows.map(function (rw) {
+ const t = rw.playTint || {};
+ return String(rw.id) + ':' + String(rw.score) + ':' + (rw.eliminated ? 1 : 0)
+ + ':' + String(rw.characterId || '') + ':' + String(t.body || '') + ':' + String(t.head || '');
+ }).join(','),
+ ].join('|');
+ if (ul.dataset.cyberSig === listSig) {
+ const existImgs = ul.querySelectorAll('img.play-cyber-score-av');
+ if (existImgs.length === hudRows.length) {
+ for (let i = 0; i < existImgs.length; i++) {
+ if (!cyberHudScoreAvatarImgLooksReady(existImgs[i])) setCyberHudScoreAvatarImg(existImgs[i], hudRows[i]);
+ }
+ return;
+ }
+ }
+ ul.dataset.cyberSig = listSig;
+ ul.innerHTML = '';
+ for (let r = 0; r < hudRows.length; r++) {
+ const row = hudRows[r];
+ const li = document.createElement('li');
+ li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '')
+ + (cyberQmMockHud ? ' play-cyber-score-row--qm-mock' : '')
+ + (mg2MockHud ? ' play-cyber-score-row--mg2-mock' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-row--crown-strip' : '');
+ if (row.id != null) li.setAttribute('data-peer-id', String(row.id));
+ const av = document.createElement('img');
+ av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '')
+ + (mg2MockHud ? ' play-cyber-score-av--mg2' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-score-av--crown-strip' : '');
+ av.alt = '';
+ setCyberHudScoreAvatarImg(av, row);
+ const sc = document.createElement('span');
+ sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '')
+ + (mg2MockHud ? ' play-cyber-mg2-score' : '')
+ + (stripScoreHud && !mg2MockHud ? ' play-cyber-crown-strip-val' : '');
+ if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
+ sc.textContent = String(row.score);
+ } else {
+ sc.textContent = row.eliminated ? '—' : String(r + 1);
+ }
+ if (cyberQmMockHud) {
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ sc.insertBefore(crown, sc.firstChild);
+ }
+ const qmLeft = document.createElement('div');
+ qmLeft.className = 'play-cyber-qm-left';
+ const avWrap = document.createElement('span');
+ avWrap.className = 'play-cyber-qm-av-wrap';
+ if (tfMockHud) {
+ /* mng8a80o: หน้าตัวละครต้องอยู่ "บน" กรอบ (กรอบกลางทึบ) + clip ให้อยู่ในขอบ */
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-qm-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ } else {
+ avWrap.appendChild(av);
+ }
+ const qmName = document.createElement('span');
+ qmName.className = 'play-cyber-qm-name';
+ qmName.textContent = String(row.nickname || '').trim().slice(0, 28);
+ qmLeft.appendChild(avWrap);
+ if (tfMockHud) {
+ /* mockup Minigame-1: avatar ซ้าย | (ชื่อบน / คะแนนล่าง) ในคอลัมน์ meta */
+ const qmMeta = document.createElement('div');
+ qmMeta.className = 'play-cyber-qm-meta';
+ qmMeta.appendChild(qmName);
+ qmMeta.appendChild(sc);
+ qmLeft.appendChild(qmMeta);
+ li.appendChild(qmLeft);
+ } else {
+ qmLeft.appendChild(qmName);
+ li.appendChild(qmLeft);
+ li.appendChild(sc);
+ }
+ } else if (mg2MockHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-mg2-player';
+ const name = document.createElement('div');
+ name.className = 'play-cyber-mg2-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 12)));
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-mg2-av-wrap';
+ const avClip = document.createElement('span');
+ avClip.className = 'play-cyber-mg2-av-clip';
+ avClip.appendChild(av);
+ avWrap.appendChild(avClip);
+ cell.appendChild(name);
+ cell.appendChild(avWrap);
+ cell.appendChild(sc);
+ li.appendChild(cell);
+ } else if (stripScoreHud) {
+ const cell = document.createElement('div');
+ cell.className = 'play-cyber-crown-strip-cell';
+ const avWrap = document.createElement('div');
+ avWrap.className = 'play-cyber-crown-strip-av-wrap';
+ avWrap.appendChild(av);
+ const meta = document.createElement('div');
+ meta.className = 'play-cyber-crown-strip-meta';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-crown-strip-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 16)));
+ meta.appendChild(name);
+ meta.appendChild(sc);
+ cell.appendChild(avWrap);
+ cell.appendChild(meta);
+ li.appendChild(cell);
+ } else {
+ const mid = document.createElement('div');
+ mid.className = 'play-cyber-score-mid';
+ const name = document.createElement('span');
+ name.className = 'play-cyber-score-name';
+ if (row.id === leaderId && leaderId != null) {
+ const crown = document.createElement('span');
+ crown.className = 'play-cyber-lead-crown';
+ crown.textContent = '♔';
+ crown.title = 'Leader';
+ name.appendChild(crown);
+ }
+ name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
+ const st = document.createElement('span');
+ st.className = 'play-cyber-score-state';
+ st.textContent = (isQuizCarry() || quizMissionHud)
+ ? ''
+ : (row.eliminated
+ ? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE'))
+ : (isBalloonBoss()
+ ? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
+ const ox = others.get(row.id);
+ return ox ? (ox.balloonBossBalloons | 0) : 0;
+ })())))
+ : 'LINK_OK'));
+ mid.appendChild(name);
+ mid.appendChild(st);
+ li.appendChild(av);
+ li.appendChild(mid);
+ li.appendChild(sc);
+ }
+ ul.appendChild(li);
+ }
+ }
+
+ function draw() {
+ try {
+ if (!mapData) {
+ document.documentElement.classList.remove('play-stack-tower-pixel-canvas');
+ return;
+ }
+ document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay());
+ } catch (e) { /* ignore */ }
+ if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ const w = mapData.width, h = mapData.height;
+
+ let zDraw = computePlayCameraZDrawPlay();
+ const stackCam = isStack() ? getStackCameraCentersPx() : null;
+ let camX = stackCam ? stackCam.px : me.x * tileSize;
+ let camY = stackCam ? stackCam.py : me.y * tileSize;
+ if (isJumpSurvive()) {
+ camX = jumpSurviveCamCenterX;
+ camY = jumpSurviveCamCenterY;
+ }
+ if (isSpaceShooter() || isBalloonBoss()) {
+ const mwPx = w * tileSize, mhPx = h * tileSize;
+ camX = mwPx * 0.5;
+ camY = mhPx * 0.5;
+ } else if (isQuizCarry()) {
+ const qc = getQuizCarryMapCameraWorldCenterPxPlay();
+ if (qc) {
+ camX = qc.cx;
+ camY = qc.cy;
+ }
+ } else if (isQuizQuestionMissionHudActivePlay()) {
+ const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
+ if (qmc) {
+ camX = qmc.cx;
+ camY = qmc.cy;
+ }
+ } else if (isQuizBattle()) {
+ /* บอร์ดควิซแนวตั้ง (20×40) — ล็อกกล้องกลางแนวนอน (ไม่ไถลซ้าย/ขวาตามผู้เล่น) เลื่อนตามแนวตั้งเท่านั้น ตรง mock */
+ camX = (w * tileSize) / 2;
+ }
+ const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw);
+ if (gauntletGroupCam) {
+ camX = gauntletGroupCam.px;
+ camY = gauntletGroupCam.py;
+ }
+ /* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */
+ lastPlayZDrawForInput = zDraw;
+ const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
+ const halfW = canvas.width / (2 * zDraw);
+ const halfH = canvas.height / (2 * zDraw);
+
+ // world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
+ const worldMinX = camX - halfW;
+ const worldMaxX = camX + halfW;
+ const worldMinY = camY - halfH;
+ const worldMaxY = camY + halfH;
+
+ const mapWpx = w * tileSize, mapHpx = h * tileSize;
+ const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
+ const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
+ const timeMs = Date.now();
+
+ if (playScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawPlayScrollBgFullCanvas(canvas.width, canvas.height);
+ } else if (gauntletCrownRunwayBgDrawActivePlay()) {
+ ctx.fillStyle = '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, {
+ worldMinX,
+ worldMaxX,
+ camX,
+ camY,
+ zDraw: zDraw,
+ mapWpx,
+ mapHpx,
+ });
+ } else if (stackTowerScrollBgDrawActive()) {
+ ctx.fillStyle = '#0a0e22';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerScrollBgFullCanvas(canvas.width, canvas.height, zDraw);
+ } else if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
+ /* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, canvas.width, canvas.height);
+ const natW = mapBackgroundImg.naturalWidth;
+ const natH = mapBackgroundImg.naturalHeight;
+ const bgMinX = Math.max(0, worldMinX);
+ const bgMaxX = Math.min(mapWpx, worldMaxX);
+ const bgMinY = Math.max(0, worldMinY);
+ const bgMaxY = Math.min(mapHpx, worldMaxY);
+ if (bgMaxX > bgMinX && bgMaxY > bgMinY) {
+ const srcX = (bgMinX / mapWpx) * natW;
+ const srcY = (bgMinY / mapHpx) * natH;
+ const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
+ const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
+ const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
+ const destY = (bgMinY - camY) * zDraw + canvas.height / 2
+ - (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0);
+ const destW = (bgMaxX - bgMinX) * zDraw;
+ const destH = (bgMaxY - bgMinY) * zDraw;
+ if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
+ ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
+ }
+ }
+ } else {
+ ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ if (isSpaceShooter()) {
+ ctx.save();
+ for (let s = 0; s < 120; s++) {
+ const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
+ const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
+ const br = (s % 4 === 0) ? 1.6 : 0.9;
+ ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
+ ctx.beginPath();
+ ctx.arc(sx, sy, br, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ } else if (isBalloonBoss()) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
+ for (let col = 0; col < 24; col++) {
+ const x = (col + 0.5) * (canvas.width / 24);
+ for (let r = 0; r < 18; r++) {
+ const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
+ ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
+ ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
+ }
+ }
+ ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
+ ctx.beginPath();
+ ctx.moveTo(0, canvas.height);
+ const baseY = canvas.height * 0.88;
+ for (let k = 0; k <= 40; k++) {
+ const bx = (k / 40) * canvas.width;
+ const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
+ ctx.lineTo(bx, by);
+ }
+ ctx.lineTo(canvas.width, canvas.height);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ function worldToScreen(wx, wy) {
+ const sx = (wx - camX) * zDraw + canvas.width / 2;
+ const sy = (wy - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ return [sx, sy];
+ }
+
+ function drawGridImagePlacementsLayer() {
+ if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
+ for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
+ const sp = mapData.gridImagePlacements[gi];
+ const wx0 = sp.x * tileSize;
+ const wy0 = sp.y * tileSize;
+ const ww = sp.w * tileSize;
+ const wh = sp.h * tileSize;
+ if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
+ let gim = mapGridImageImgs[sp.i];
+ let carrySpriteAlpha = 1;
+ if (isQuizCarry()) {
+ const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
+ const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp);
+ const meHolding = me && me.quizCarryHeld != null;
+ let carryVisualActive = false;
+ if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true;
+ else if (domOpt == null && onHub && meHolding) carryVisualActive = true;
+ if (carryVisualActive) {
+ const gh = mapGridImageHeldImgs[sp.i];
+ if (gh && gh.complete && gh.naturalWidth) gim = gh;
+ else carrySpriteAlpha = 0.32;
+ }
+ }
+ if (!gim || !gim.complete || !gim.naturalWidth) continue;
+ const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
+ const sy0 = (wy0 - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
+ const sw = ww * zDraw;
+ const sh = wh * zDraw;
+ ctx.save();
+ if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha;
+ ctx.beginPath();
+ ctx.rect(sx0, sy0, sw, sh);
+ ctx.clip();
+ ctx.drawImage(gim, sx0, sy0, sw, sh);
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
+ }
+ }
+
+ const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
+ const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
+ const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = isFrogger() ? getLane(y) : null;
+ let rowFill = null;
+ if (lane) {
+ if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
+ else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
+ else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
+ else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
+ } else if (isGauntlet()) {
+ rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
+ } else if (isStack()) {
+ rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
+ } else if (isJumpSurvive()) {
+ rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
+ } else if (isSpaceShooter()) {
+ rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
+ } else if (isBalloonBoss()) {
+ rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
+ } else if (isQuizBattle()) {
+ rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
+ }
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const ob = mapData.objects?.[y]?.[x] ?? 0;
+ if (ob === 1) {
+ ctx.fillStyle = 'rgba(65,72,104,0.92)';
+ ctx.fillRect(sx, sy, size, size);
+ ctx.strokeStyle = '#565f89';
+ ctx.strokeRect(sx, sy, size, size);
+ } else {
+ const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
+ if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
+ else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
+ else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
+ ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
+ ctx.fillRect(sx, sy, size, size);
+ }
+ }
+ }
+ }
+ }
+ drawGridImagePlacementsLayer();
+ if (showGrid) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ for (let x = startTileX; x <= endTileX; x++) {
+ const wx = x * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw;
+ const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
+ if (isInter) {
+ ctx.fillStyle = 'rgba(158,206,106,0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(158,206,106,0.8)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (isQuiz()) {
+ const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isQuizQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
+ if (isQuizT) {
+ ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
+ if (isQuizF) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ }
+ if (isQuizCarry()) {
+ const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
+ if (hub) {
+ ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
+ if (isCarryQ) {
+ ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
+ if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
+ ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
+ /* ไฮไลต์เส้นทางบาง ๆ (โชว์เฉพาะตอนเปิดกริด) ให้เห็นถนนจาก design ทะลุ */
+ ctx.fillStyle = 'rgba(150, 195, 255, 0.12)';
+ ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
+ }
+ if (isQuizBattle() && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) {
+ const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x];
+ const comp = mapData.quizBattleDomeComp;
+ /* วาดหีบครั้งเดียวต่อโดม (ช่องบน-ซ้ายสุดของกลุ่ม comp) — กันวาดซ้อน */
+ const upSame = cid > 0 && comp && comp[y - 1] && comp[y - 1][x] === cid;
+ const leftSame = cid > 0 && comp && comp[y] && comp[y][x - 1] === cid;
+ if (!upSame && !leftSame) {
+ const passed = cid > 0 && quizBattlePassedComps.has(cid);
+ const wrong = passed && !quizBattleAnsweredComps.has(cid);
+ drawQuizBattleChest(ctx, sx + size / 2, sy + size / 2, size, passed, cid || 0, wrong);
+ }
+ }
+ if (isStack()) {
+ const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea;
+ if (sr && sr[y] && sr[y][x] === 1) {
+ ctx.fillStyle = 'rgba(125, 207, 255, 0.28)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)';
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ }
+ if (sl && sl[y] && sl[y][x] === 1) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.26)';
+ ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
+ ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)';
+ ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
+ }
+ }
+ /* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */
+ if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
+ const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
+ if (sv >= 1 && sv <= 6) {
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
+ ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
+ ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
+ ctx.textAlign = 'left';
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && mapData.jumpSurvivePlatformArea) {
+ const pa = mapData.jumpSurvivePlatformArea;
+ const sc = jumpSurvivePlatformScrollPx;
+ const tsz = tileSize;
+ const period = h * tsz;
+ const visTop = worldMinY;
+ const visBot = worldMaxY;
+ const eps = 1e-6;
+ for (let py = 0; py < h; py++) {
+ for (let px = 0; px < w; px++) {
+ if (!pa[py] || pa[py][px] !== 1) continue;
+ const vIdx = jumpSurvivePlatformVariantIndexAtPlay(mapData, px, py);
+ if (px > 0 && pa[py][px - 1] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px - 1, py) === vIdx) {
+ continue;
+ }
+ let runLen = 1;
+ while (px + runLen < w && pa[py][px + runLen] === 1
+ && jumpSurvivePlatformVariantIndexAtPlay(mapData, px + runLen, py) === vIdx) runLen += 1;
+ const cfg = playJumpSurvivePlatformTiles[vIdx - 1] || { url: '', w: 0, h: 0 };
+ const jumpPlatU = normalizeGauntletAssetUrlForPlay(cfg.url || '');
+ const jumpPlatRec = jumpPlatU ? ensureGauntletAssetImage(jumpPlatU) : null;
+ const jumpPlatImg = jumpPlatRec && jumpPlatRec.ready && jumpPlatRec.img && jumpPlatRec.img.naturalWidth > 0 ? jumpPlatRec.img : null;
+ const tileWorldLeft = px * tsz;
+ const tileWorldRight = (px + runLen) * tsz;
+ if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
+ const rawTop = py * tsz + sc;
+ const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
+ const kMax = Math.floor((visBot - rawTop - eps) / period);
+ if (kMax < kMin) continue;
+ for (let kk = kMin; kk <= kMax; kk++) {
+ const platTop = rawTop + kk * period;
+ if (platTop + tsz <= visTop || platTop >= visBot) continue;
+ const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
+ const psz = tsz * zDraw;
+ const segScreenW = runLen * psz;
+ let dw;
+ let dh = cfg.h > 0 ? cfg.h * zDraw : psz - 4;
+ if (runLen > 1) {
+ dw = segScreenW;
+ } else {
+ dw = cfg.w > 0 ? cfg.w * zDraw : psz - 4;
+ const maxDim = psz * 4;
+ if (dw > maxDim) dw = maxDim;
+ }
+ const maxDh = psz * 4;
+ if (dh > maxDh) dh = maxDh;
+ const marginBot = 2;
+ const dx = runLen > 1 ? psx : psx + (psz - dw) / 2;
+ const dy = psy + psz - dh - marginBot;
+ if (jumpPlatImg) {
+ ctx.save();
+ try { ctx.imageSmoothingEnabled = true; } catch (e) { /* ignore */ }
+ ctx.drawImage(jumpPlatImg, dx, dy, dw, dh);
+ ctx.restore();
+ } else {
+ const fillW = runLen > 1 ? segScreenW - 6 : psz - 6;
+ ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
+ ctx.fillRect(psx + 3, psy + 3, fillW, psz - 6);
+ const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
+ grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
+ grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
+ grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(psx + 4, psy + 4, fillW - 2, psz - 8);
+ ctx.lineWidth = 1;
+ if (zDraw >= 1.15 && psz > 22) {
+ ctx.save();
+ ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const cx = psx + (runLen > 1 ? segScreenW * 0.5 : psz * 0.5);
+ ctx.fillText('DATA', cx, psy + psz * 0.38);
+ ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
+ ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
+ ctx.fillText('/SEC/BLOCK', cx, psy + psz * 0.62);
+ ctx.restore();
+ }
+ }
+ }
+ }
+ }
+ }
+ if (isJumpSurvive() && showGrid && mapData.jumpSurviveHazardArea) {
+ const ha = mapData.jumpSurviveHazardArea;
+ const tsz = tileSize;
+ for (let py = startTileY; py <= endTileY; py++) {
+ for (let px = startTileX; px <= endTileX; px++) {
+ if (!ha[py] || ha[py][px] !== 1) continue;
+ const [psx, psy] = worldToScreen(px * tsz, py * tsz);
+ const psz = tsz * zDraw;
+ ctx.fillStyle = 'rgba(200, 28, 55, 0.38)';
+ ctx.fillRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.strokeStyle = 'rgba(255, 90, 110, 0.82)';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(psx + 1, psy + 1, psz - 2, psz - 2);
+ ctx.lineWidth = 1;
+ }
+ }
+ }
+ if (showGrid) {
+ if (isFrogger() && mapData.lanes) {
+ for (let y = startTileY; y <= endTileY; y++) {
+ const lane = getLane(y);
+ if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
+ const positions = getVehiclePositions(lane, mapData.width, timeMs);
+ const isRoad = lane.type === 'road';
+ for (let i = 0; i < positions.length; i++) {
+ const vx = positions[i];
+ const wx = vx * tileSize, wy = y * tileSize;
+ const [sx, sy] = worldToScreen(wx, wy);
+ const size = tileSize * zDraw * (isRoad ? 1.2 : 1.5);
+ ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
+ ctx.fillRect(sx, sy, size, size * 0.7);
+ ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
+ ctx.strokeRect(sx, sy, size, size * 0.7);
+ }
+ }
+ }
+ }
+
+ if (isQuizCarry()) {
+ drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
+ }
+
+ if (isStack() && stackMini) {
+ drawStackMinigame(ctx, worldToScreen, zDraw);
+ }
+
+ const gauntletObsAlpha = isGauntlet() ? gauntletCrownRunwayObstacleDrawAlphaPlay() : 1;
+ const gauntletObsDraw = (isGauntlet() && gauntletObsAlpha > 0.001)
+ ? getGauntletObsDrawPositionsAt(performance.now())
+ : [];
+ if (isGauntlet() && gauntletObsDraw.length) {
+ ctx.save();
+ ctx.globalAlpha = gauntletObsAlpha;
+ const stx = Math.max(0, Math.floor(worldMinX / tileSize));
+ const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
+ const sty = Math.max(0, Math.floor(worldMinY / tileSize));
+ const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
+ for (let i = 0; i < gauntletObsDraw.length; i++) {
+ const o = gauntletObsDraw[i];
+ if (!o) continue;
+ if (o.kind === 'lane' && typeof o.y === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue;
+ const size = tileSize * zDraw;
+ const inner = Math.max(2, size - 4);
+ const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize);
+ const dx = sxC - inner / 2;
+ const dy = syCellBottom - inner;
+ const laneRec = pickGauntletLaneImageRec(o.id);
+ if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) {
+ try {
+ ctx.drawImage(laneRec.img, dx, dy, inner, inner);
+ } catch (e) {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else {
+ ctx.fillStyle = '#f7768e';
+ ctx.fillRect(dx, dy, inner, inner);
+ }
+ } else if (o.kind === 'laser' && typeof o.drawX === 'number') {
+ if (o.drawX < stx - 2 || o.drawX > enx + 2) continue;
+ /* ใช้โซนที่ editor กำหนด (gauntletLaserRowStart..End เช่น 4..18) — หัว/ท้าย emitter อยู่ตามนั้น */
+ const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h);
+ const wx0 = o.drawX * tileSize;
+ const wx1 = (o.drawX + 1) * tileSize;
+ const wy0 = laserRow0 * tileSize;
+ const wy1 = (laserRow1 + 1) * tileSize;
+ const [sx0, syTop] = worldToScreen(wx0, wy0);
+ const [sx1, syBot] = worldToScreen(wx1, wy1);
+ const rx = Math.min(sx0, sx1);
+ const rw = Math.max(2, Math.abs(sx1 - sx0));
+ const ry = Math.min(syTop, syBot);
+ const rh = Math.abs(syBot - syTop);
+ drawGauntletLaserColumnScreen(rx, ry, rw, rh);
+ }
+ }
+ ctx.restore();
+ }
+
+ /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */
+ function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) {
+ const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl);
+ const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null;
+ const raw = String(signText || '').trim();
+ if (!raw && !imgUrlSan && !gridHeldDraw) return;
+ ctx.save();
+ const heldPs = quizCarryPlaqueMapScaleClampedPlay();
+ const bodyH = Math.max(18, feetSy - spriteTopY);
+ const midBodyY = spriteTopY + bodyH * 0.48;
+ const bodyCx = sxFeet;
+ const halfW = Math.max(10, halfSpriteW || 0);
+ const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5));
+ const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs));
+
+ const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs));
+ ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`;
+ const padX = 12;
+ const padY = 9;
+ const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2));
+ let lines;
+ if (!raw) {
+ lines = [];
+ } else {
+ lines = canvasWordWrapLines(ctx, raw, maxInner);
+ if (!lines.length) lines = [raw];
+ if (lines.length > 3) lines = lines.slice(0, 3);
+ for (let i = 0; i < lines.length; i++) {
+ let s = lines[i];
+ if (ctx.measureText(s).width <= maxInner) continue;
+ while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1);
+ lines[i] = s + '…';
+ }
+ }
+ let maxLineW = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lw = ctx.measureText(lines[i]).width;
+ if (lw > maxLineW) maxLineW = lw;
+ }
+ const lineH = fontPx * 1.22;
+ let w;
+ let h;
+ if (!lines.length && (imgUrlSan || gridHeldDraw)) {
+ w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2)));
+ h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2));
+ } else {
+ w = Math.ceil(Math.max(maxLineW, 56) + padX * 2);
+ h = Math.ceil(lines.length * lineH + padY * 2);
+ w = Math.min(maxPlaqueW, Math.max(96, w));
+ h = Math.max(40, h);
+ }
+
+ let signX = bodyCx - w / 2;
+ let signY = midBodyY - h / 2;
+
+ if (signX + w > canvas.width - 6) signX = canvas.width - w - 6;
+ if (signX < 6) signX = 6;
+ if (signY < 6) signY = 6;
+ if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
+
+ drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw });
+ ctx.restore();
+ }
+
+ function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload, crownPenaltyFxUntil) {
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const air = Number(gauntletAirTicks) || 0;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ /* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const boxW = tileSize * zDraw * Math.max(cw, 1) * 1.15;
+ const boxH = tileSize * zDraw * Math.max(ch, 1) * 1.35;
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const dir = direction || 'down';
+ const charImg = resolvePlayDrawCharSource(characterId, dir, timeMs, isWalking, playTint, !!isMe);
+ /* รองรับทั้ง และ canvas จากย้อมสี — กล่อง W×H ตาม footprint; อนุญาต upscale เลเยอร์เล็ก (ห้ามจำกัด scale≤1 ไม่งั้นตัวหดเป็นจุด) */
+ let iw = 0, ih = 0;
+ if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
+ iw = charImg.width;
+ ih = charImg.height;
+ } else if (charImg && charImg.complete && charImg.naturalWidth > 0 && charImg.naturalHeight > 0) {
+ iw = charImg.naturalWidth;
+ ih = charImg.naturalHeight;
+ }
+ let halfSpriteW = r;
+ let spriteTopY = sy - 2 * r;
+ if (iw > 0 && ih > 0) {
+ const scale = Math.min(boxW / iw, boxH / ih);
+ let drawW = iw * scale;
+ let drawH = ih * scale;
+ if (isQuiz()) {
+ const minPx = Math.max(28, r * 1.85);
+ if (drawW < minPx || drawH < minPx) {
+ const up = Math.max(minPx / Math.max(drawW, 0.01), minPx / Math.max(drawH, 0.01));
+ drawW *= up;
+ drawH *= up;
+ }
+ }
+ halfSpriteW = drawW / 2;
+ spriteTopY = sy - drawH;
+ ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
+ } else {
+ const cy = sy - r;
+ ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
+ ctx.beginPath();
+ ctx.arc(sx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#c0caf5';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ spriteTopY = cy - r;
+ }
+ ctx.fillStyle = '#c0caf5';
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ const nameBase = name || '';
+ /* gauntlet (Last Light) live: ชื่อ/คะแนน/-10 ใช้ DOM overlay (syncGauntletNameDom) — ไม่วาดบน canvas */
+ /* crown map (Minigame-2): ไม่วาดชื่อ/คะแนน/-10 ใต้ตัวละครบน canvas "ทุกเฟส" (ตรง mock — โชว์เฉพาะแถบคะแนนบน)
+ เดิมเช็คเฉพาะ phase==='live' → ตอน summary/howto canvas วาดชื่อกลับมา ทับแถบคะแนน (ดูเหมือน avatar ซ้อน) */
+ const gntDomLabel = isGauntletCrownHeistMapPlay();
+ const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
+ ? (nameBase + ' · ' + gauntletScoreLabel)
+ : nameBase;
+ if (!gntDomLabel) ctx.fillText(withScore, sx, sy + 10);
+ if (!gntDomLabel
+ && isGauntletCrownHeistMapPlay()
+ && gauntletCrownPregamePhase === 'live'
+ && typeof crownPenaltyFxUntil === 'number'
+ && crownPenaltyFxUntil > timeMs) {
+ const penImg = ensureGauntletCrownScorePenaltyImgPlay();
+ if (penImg && penImg.complete && penImg.naturalWidth > 0) {
+ const ref = Math.max(18, r * 2);
+ const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1));
+ const dw = penImg.naturalWidth * scale;
+ const dh = penImg.naturalHeight * scale;
+ ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh);
+ }
+ }
+ if (isQuizCarry() && quizCarrySignPayload) {
+ drawQuizCarryHeldSignBoard(
+ sx, sy, halfSpriteW, spriteTopY,
+ quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
+ quizCarrySignPayload.imageUrl,
+ quizCarrySignPayload.imageElement,
+ );
+ }
+ }
+ function quizCarrySignForEntity(ent) {
+ if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
+ const idx = ent.quizCarryHeld;
+ const ch = quizCarryCurrent.choices;
+ if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
+ const t = String(ch[idx] != null ? ch[idx] : '').trim();
+ const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
+ const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
+ if (!t && !imgUrl && !gridHeldEl) return null;
+ return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
+ }
+ const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
+ function peerVisualOffset(id) {
+ let h = 0;
+ for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
+ return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
+ }
+ function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
+ if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
+ if (!ent || ent.gauntletEliminated) return true;
+ const { cw, ch } = getCharacterFootprintWH(mapData);
+ const ax = safeX(ent.x) + (offAx || 0);
+ const ay = safeY(ent.y) + (offAy || 0);
+ const airTicks = isMeFlag ? (meGauntletJumpTicks || 0) : (Number(ent.gauntletJumpTicks) || 0);
+ const airVis = isMeFlag ? (meGauntletJumpVis || 0) : (Number(ent.gauntletJumpVis) || 0);
+ const air = airVis > 0.08 ? airVis : airTicks;
+ let liftWorldY = 0;
+ if (isGauntlet() && air > 0) {
+ liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
+ }
+ const cxWorld = (ax + cw * 0.5) * tileSize;
+ const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
+ const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
+ const cellSpan = Math.max(cw, ch);
+ const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
+ const pad = Math.max(28, r * 0.4);
+ if (sx + r + pad < 0 || sx - r - pad > canvas.width) return true;
+ if (sy + 52 < 0 || sy - r * 4 - pad > canvas.height) return true;
+ return false;
+ }
+ const { ch: sortCh } = getCharacterFootprintWH(mapData);
+ /** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */
+ function avatarDrawDepth(ent) {
+ return Math.floor(safeY(ent.y)) + sortCh;
+ }
+ if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
+ let mt = me.playTint;
+ if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') {
+ mt = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')));
+ me.playTint = mt;
+ }
+ const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
+ const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
+ const meWalking = isFrogger()
+ ? false
+ : (isGauntlet()
+ ? ((crownRunStripLive && !me.gauntletEliminated) || meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08)
+ : !!me.isWalking);
+ const drawRows = [];
+ others.forEach((o, id) => {
+ if (!o) return;
+ drawRows.push({ isMe: false, id, o });
+ });
+ drawRows.push({ isMe: true, id: '__me', o: me });
+ drawRows.sort((a, b) => {
+ const da = avatarDrawDepth(a.o);
+ const db = avatarDrawDepth(b.o);
+ if (da !== db) return da - db;
+ const xa = Math.floor(safeX(a.o.x) * 4);
+ const xb = Math.floor(safeX(b.o.x) * 4);
+ if (xa !== xb) return xa - xb;
+ const sa = a.isMe ? '\uFFFFme' : String(a.id);
+ const sb = b.isMe ? '\uFFFFme' : String(b.id);
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
+ });
+ drawRows.forEach((row) => {
+ if (!row.isMe) {
+ const id = row.id;
+ const o = row.o;
+ const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
+ const otherWalk = isGauntlet()
+ ? ((crownRunStripLive && !o.gauntletEliminated) || (o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
+ : (isPreviewBotId(id)
+ ? !!o.botIsWalking
+ : !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
+ const ot = o.playTint || resolvePlayTintForPeer(id);
+ const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction;
+ const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
+ const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
+ const axO = safeX(o.x) + off.ax;
+ const ayO = safeY(o.y) + off.ay;
+ /* ผีต้องโชว์เสมอ แม้ทับโซนคำตอบ (เดินไปไหนก็ได้) */
+ const ghostActiveO = isQuizWrongGhostActiveForEnt(id);
+ if (!ghostActiveO && playQuizAvatarHideWhileOverlappingAnswerZone(o, id, axO, ayO)) return;
+ if (botOut) ctx.save();
+ if (botOut) ctx.globalAlpha = 0.4;
+ /* ตอบผิดในแมป mng8a80o → แทน avatar ด้วยผีเลย */
+ if (ghostActiveO) {
+ const ga = _quizGhostScreenAnchorPlay(axO, ayO, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(ga.sx, ga.sy, ga.sizeH, id, peerName);
+ } else {
+ drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(o),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (botOut) ctx.restore();
+ } else {
+ if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
+ const axMe = safeX(me.x);
+ const ayMe = safeY(me.y);
+ const ghostActiveMe = isQuizWrongGhostActiveForEnt(myId);
+ if (!ghostActiveMe && playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
+ const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
+ if (ghostActiveMe) {
+ const gam = _quizGhostScreenAnchorPlay(axMe, ayMe, worldToScreen, zDraw);
+ drawQuizWrongGhostOverlayPlay(gam.sx, gam.sy, gam.sizeH, myId, me.nickname + meTag);
+ } else {
+ drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis,
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletScore || 0) : undefined,
+ quizCarrySignForEntity(me),
+ (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live') ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
+ }
+ if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
+ }
+ });
+ /* Minigame-2: อัปเดต DOM overlay (ชื่อ/คะแนน/-10) ให้ตรงตำแหน่งตัวละครบน canvas */
+ try { syncGauntletNameDom(worldToScreen, zDraw, tileSize); } catch (eGntDom) { /* ignore */ }
+ }
+ /* ไอคอนคำถามพิเศษในฉาก (วาดทับ map/ผู้เล่น) + ตรวจการเดินชน */
+ if (specialQuizIcon) {
+ drawSpecialQuizIconWorld(worldToScreen, zDraw);
+ checkSpecialQuizCollision();
+ }
+ if (isFrogger()) {
+ ctx.fillStyle = '#7aa2f7';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
+ }
+ if (isJumpSurvive() && !useCyberPlayHud()) {
+ ctx.fillStyle = 'rgba(148, 226, 213, 0.95)';
+ ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
+ ctx.textAlign = 'left';
+ if (jumpSurviveEliminated) {
+ ctx.fillStyle = 'rgba(247, 118, 190, 0.95)';
+ ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22);
+ } else {
+ ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22);
+ }
+ }
+ if (isGauntlet() && !useCyberPlayHud()) {
+ ctx.fillStyle = '#f7768e';
+ ctx.font = 'bold 14px sans-serif';
+ ctx.textAlign = 'left';
+ if (isGauntletCrownHeistMapPlay()) {
+ /* Minigame-2 (Last Light): ใช้ cyber HUD + DOM overlay แล้ว — ไม่วาด debug text (คะแนน · ชน -10 · ขอบซ้ายตาย) บน canvas
+ (เดิมโผล่มุมซ้ายบนทับ SCORE label เวลา useCyberPlayHud() = false ใน preview embed) */
+ } else {
+ ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42);
+ }
+ ctx.fillStyle = '#e0af68';
+ if (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) {
+ if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
+ const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
+ const mm = Math.floor(rem / 60);
+ const ss = rem % 60;
+ const clock = `${mm}:${String(ss).padStart(2, '0')}`;
+ ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
+ } else if (gauntletRuntimeTimeLimitSec > 0) {
+ ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
+ } else {
+ ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
+ }
+ }
+ }
+ if (isStack() && stackMini) {
+ if (playBotsEnabled()) {
+ if (!playHideTopChrome()) {
+ ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
+ ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
+ }
+ /* Tower mnn93hpi: แผงขวา (ชีวิตทีม + อวตาร์) วาดบน canvas — ต้องวาดแม้ซ่อนแถบห้องบน */
+ if (!playHideTopChrome() || (isStackTowerMissionUiMapPlay() && useCyberPlayHud())) {
+ drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
+ }
+ } else {
+ ctx.fillStyle = '#7dcfff';
+ ctx.font = 'bold 15px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
+ ctx.font = '12px sans-serif';
+ ctx.fillStyle = '#c0caf5';
+ ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
+ }
+ }
+ if (isQuiz() || isQuizCarry() || isQuizBattle()) {
+ syncPlayQuizMapPanel();
+ syncQuizCarryEmbedQuestionStrip();
+ }
+ /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */
+ syncQuizCarryTimeupDeskLayerPosition();
+ if (playBotsEnabled() && mapData && !useCyberPlayHud()) {
+ const human = countPlayHumans();
+ const bots = [...others.keys()].filter(isPreviewBotId).length;
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = 'rgba(26,27,38,0.78)';
+ const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
+ ctx.fillRect(6, canvas.height - 38, hudW, 30);
+ ctx.fillStyle = '#a9b1d6';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'left';
+ const botTarget = playBotTargetHeadcount();
+ let line = detectiveCaseFillBots
+ ? ('คดีสืบสวน: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)')
+ : ('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)');
+ if (isStack()) {
+ line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
+ }
+ ctx.fillText(line, 12, canvas.height - 18);
+ ctx.restore();
+ }
+ if (isSpaceShooter()) {
+ drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isBalloonBoss()) {
+ drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (isQuizCarry()) {
+ drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs);
+ }
+ if (quizCarryEmbedMissionFlowActive()
+ && (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) {
+ syncQuizCarryEmbedCountdownLayout();
+ }
+ drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs);
+ syncPlayCyberHud();
+ syncQuizQuestionMissionJoystickPlay();
+ syncQuizCarryGrabButton();
+ syncGauntletCrownJumpButton();
+ syncStackTowerDropButtonPlay();
+ syncStackTowerMissionDomHudPlay();
+ syncSpaceShooterLifeDomHudPlay();
+ syncQuizBattleHudPlay();
+ }
+
+ document.addEventListener('keydown', (e) => {
+ /* Test Mode shortcut: Ctrl+Q = ตอบถูกใน special quiz (server เติมคำตอบให้) */
+ if (e.ctrlKey && (e.code === 'KeyQ' || e.key === 'q' || e.key === 'Q')) {
+ if (specialQuizActiveQuestion && !specialQuizAnswered && __isTestModeOnPlay()) {
+ e.preventDefault();
+ specialQuizAnswered = true;
+ const status = document.getElementById('sq-ov-status');
+ if (status) status.textContent = '[Test] ส่งคำตอบที่ถูกอัตโนมัติ — รอผู้เล่นคนอื่น...';
+ if (specialQuizSelfId()) markSpecialQuizMemberAnswered(specialQuizSelfId());
+ const choicesEl = document.getElementById('sq-ov-choices');
+ if (choicesEl) Array.prototype.forEach.call(choicesEl.children, function (b) { b.disabled = true; });
+ try { socket.emit('special-quiz-answer', { debugCorrect: true }, function () {}); } catch (eQ) { /* ignore */ }
+ return;
+ }
+ }
+ if (isMovementKey(e.code) && isChatFocused()) return;
+ if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) {
+ if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, playEmbedUserZoomMul / PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ if (
+ e.code === 'BracketRight'
+ || e.code === 'NumpadAdd'
+ || (e.code === 'Equal' && e.shiftKey)
+ ) {
+ e.preventDefault();
+ playEmbedUserZoomMul = Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * PLAY_EMBED_ZOOM_STEP_KEY);
+ draw();
+ return;
+ }
+ }
+ if (isSpaceShooter() && mapData && !isChatFocused() && isSpaceShooterMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isQuiz() && mapData && !isChatFocused() && isQuizQuestionMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isStack() && mapData && !isChatFocused() && isStackTowerMissionPregameBlockingPlay()) {
+ if (['Space', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ return;
+ }
+ if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+ if (isStack() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'Enter' || e.code === 'NumpadEnter') {
+ e.preventDefault();
+ tryHumanStackDrop();
+ return;
+ }
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isGauntlet() && mapData && !isChatFocused()) {
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ tryRequestGauntletJumpPlay();
+ return;
+ }
+ if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isJumpSurvive() && mapData && !isChatFocused()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (jumpSurviveEliminated) {
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ if (!e.repeat) jumpSurviveJumpQueued = true;
+ return;
+ }
+ if (e.code === 'ArrowDown' || e.code === 'KeyS') {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (isQuizBattle() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyE') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
+ return;
+ }
+ }
+ if (isQuizCarry() && mapData && !isChatFocused()) {
+ if (e.code === 'KeyF') {
+ e.preventDefault();
+ if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ return;
+ }
+ }
+ keys[e.code] = true;
+ keys[e.key] = true;
+ if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
+ if (isFrogger() && mapData && !isChatFocused()) {
+ const now = Date.now();
+ if (now - lastFroggerKey < 280) return;
+ let dx = 0, dy = 0;
+ if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
+ else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
+ else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
+ else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
+ if (dx !== 0 || dy !== 0) {
+ const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
+ if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
+ const row = mapData.objects && mapData.objects[ny];
+ if (!row || row[nx] !== 1) {
+ me.x = nx; me.y = ny;
+ lastFroggerKey = now;
+ socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
+ const lane = getLane(Math.floor(me.y));
+ if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
+ else if (checkFroggerCollision()) respawnFrogger();
+ }
+ }
+ }
+ }
+ });
+ document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
+
+ let lastSend = 0;
+ function tick() {
+ if (!mapData) { requestAnimationFrame(tick); return; }
+ /* คำถามพิเศษเปิดอยู่ — หยุดเกมทุกอย่าง (input/sim/timer) เห็นแต่ภาพค้างไว้
+ ดันเวลาทุกตัวไปข้างหน้าเท่า delta ทุกเฟรม → countdown ที่แสดงหยุดนิ่งจริง */
+ if (specialQuizFreeze) {
+ tickSpecialQuizFreezeShift();
+ if (me) me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isStack()) {
+ if (isStackTowerEmbedZoomLockedPlay()) {
+ playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
+ }
+ const nowStBg = performance.now();
+ const dtStBg = Math.min(0.06, (nowStBg - lastStackTowerScrollBgTickMs) / 1000);
+ lastStackTowerScrollBgTickMs = nowStBg;
+ tickStackTowerScrollBg(dtStBg);
+ if (isStackTowerMissionPregameBlockingPlay()) {
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ stackTickFrame();
+ updateStackTowerSwingYForStripGapPlay();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isJumpSurvive()) {
+ if (isJumpSurviveMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ jumpSurviveTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuiz() && isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isSpaceShooter()) {
+ if (isSpaceShooterMissionPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ spaceShooterTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isBalloonBoss()) {
+ balloonBossTickFrame();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isFrogger()) {
+ if (checkFroggerCollision()) respawnFrogger();
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isGauntlet()) {
+ {
+ const nowBg = performance.now();
+ const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016);
+ lastGauntletCrownRunwayBgTickMs = nowBg;
+ tickGauntletCrownRunwayBgPlay(dtBg);
+ }
+ if (isGauntletCrownPregameBlockingPlay()) {
+ me.isWalking = false;
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()) || meGauntletJumpTicks > 0;
+ const stepGauntletJumpVis = (vis, tgt) => {
+ const t = Number(tgt) || 0;
+ let v = Number(vis) || 0;
+ const d = t - v;
+ const k = d < 0 ? 0.68 : 0.4;
+ return v + d * k;
+ };
+ meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
+ const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
+ me.x = mp.nx;
+ me.y = mp.ny;
+ others.forEach((o) => {
+ const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
+ o.x = op.nx;
+ o.y = op.ny;
+ if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
+ o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
+ });
+ clampPlayEntityFootprintToMap(me, mapData);
+ others.forEach((o) => {
+ if (o) clampPlayEntityFootprintToMap(o, mapData);
+ });
+ let zGa = zoom;
+ if (previewMode && editorEmbedReturn && mapData) zGa *= playEmbedUserZoomMul;
+ const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa);
+ if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa);
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
+ others.forEach((o, id) => {
+ if (playBotsEnabled() && isPreviewBotId(id)) return;
+ if (o.tx != null) o.x += (o.tx - o.x) * LERP;
+ if (o.ty != null) o.y += (o.ty - o.y) * LERP;
+ });
+ tickQuizCarryEmbedCountdown();
+ tickQuizCarryRoundTimers();
+ updateQuizCarryCarryPhaseHud();
+ stepPreviewBots();
+ if (isChatFocused()) {
+ me.isWalking = false;
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) {
+ me.isWalking = false;
+ playPath = [];
+ enforceQuizBattleLaneOnMePlay();
+ draw();
+ requestAnimationFrame(tick);
+ return;
+ }
+ const w = mapData.width || 20, h = mapData.height || 15;
+ let accX = 0, accY = 0;
+ let usePath = false;
+ const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD'];
+ if (playPath.length > 0 && keyPressed) playPath = [];
+ if (playPath.length > 0) {
+ const way = playPath[0];
+ const dx = way.x - me.x, dy = way.y - me.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= PATH_ARRIVE_THRESH) {
+ playPath.shift();
+ /* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */
+ while (playPath.length > 0) {
+ const w2 = playPath[0];
+ const ux = w2.x - me.x, uy = w2.y - me.y;
+ if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
+ playPath.shift();
+ }
+ if (playPath.length === 0) {
+ me.isWalking = false;
+ clampPlayEntityFootprintToMap(me, mapData);
+ 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;
+ }
+ usePath = true;
+ const next = playPath[0];
+ accX = next.x - me.x;
+ accY = next.y - me.y;
+ } else {
+ usePath = true;
+ accX = dx;
+ accY = dy;
+ }
+ if (usePath && (accX !== 0 || accY !== 0)) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ if (!usePath) {
+ let kx = 0;
+ let ky = 0;
+ if (keys['ArrowLeft'] || keys['KeyA']) kx -= 1;
+ if (keys['ArrowRight'] || keys['KeyD']) kx += 1;
+ if (keys['ArrowUp'] || keys['KeyW']) ky -= 1;
+ if (keys['ArrowDown'] || keys['KeyS']) ky += 1;
+ let jx = 0;
+ let jy = 0;
+ if (isQuizQuestionMissionHudActivePlay()) {
+ jx = quizQuestionMissionJoyVecX;
+ jy = quizQuestionMissionJoyVecY;
+ }
+ accX = kx + jx;
+ accY = ky + jy;
+ if (accX !== 0 || accY !== 0) {
+ if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
+ else if (accX !== 0) me.direction = accX > 0 ? 'right' : 'left';
+ }
+ }
+ const preWalkX = me.x, preWalkY = me.y;
+ if (accX !== 0 || accY !== 0) {
+ const len = Math.sqrt(accX * accX + accY * accY) || 1;
+ const step = Math.min(moveSpeedTilesThisFrameForWalk(), len);
+ const tox = me.x + (accX / len) * step;
+ const toy = me.y + (accY / len) * step;
+ const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
+ if (pathStrict) {
+ const moved = quizBattleTryStepMovePlay(me.x, me.y, tox, toy, (nx, ny, ox, oy) => canWalkLikeLobby(nx, ny, ox, oy));
+ me.x = moved.x;
+ me.y = moved.y;
+ } else if (canWalkLikeLobby(tox, toy, me.x, me.y)) {
+ me.x = tox;
+ me.y = toy;
+ } else {
+ if (canWalkLikeLobby(tox, me.y, me.x, me.y)) {
+ me.x = tox;
+ } else if (canWalkLikeLobby(me.x, toy, me.x, me.y)) {
+ me.y = toy;
+ }
+ }
+ }
+ clampPlayEntityFootprintToMap(me, mapData);
+ enforceQuizBattleLaneOnMePlay();
+ enforceQuizBattleLaneOnPeersPlay();
+ const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5;
+ 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);
+ }
+
+ const chatForm = document.getElementById('chat-form');
+ if (chatForm) {
+ chatForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const input = document.getElementById('chat-input');
+ const text = (input && input.value || '').trim();
+ if (text) { socket.emit('chat', text); if (input) input.value = ''; }
+ });
+ }
+ document.getElementById('btn-leave').addEventListener('click', () => {
+ socket.emit('leave-space');
+ const mid = params.get('map');
+ if (previewMode && mid) {
+ var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : '');
+ window.location.replace(BASE + '/editor.html' + edQ);
+ } else {
+ window.location.replace(playDesignMainMenuHref());
+ }
+ });
+ (function wireQuizBattleHudButtons() {
+ /* EXIT → ออกห้อง กลับหน้าเลือกห้อง Quiz Battle */
+ const exitBtn = document.getElementById('qb-hud-exit');
+ if (exitBtn) {
+ exitBtn.addEventListener('click', () => {
+ try { saveQuizBattleProgress(); } catch (e) { /* ignore */ }
+ try { socket.emit('leave-space'); } catch (e) { /* ignore */ }
+ let href = '../Quiz-Battle/';
+ try { if (typeof appPath === 'function') href = appPath('/Quiz-Battle/'); } catch (e2) { /* ignore */ }
+ window.location.href = href;
+ });
+ }
+ /* MY RESULT → popup ผลตอบของฉัน */
+ const myResultBtn = document.getElementById('qb-hud-myresult');
+ if (myResultBtn) myResultBtn.addEventListener('click', function () { openQuizBattleMyResult(); });
+ /* RANKING → popup อันดับ (global leaderboard) */
+ const rankingBtn = document.getElementById('qb-hud-ranking');
+ if (rankingBtn) rankingBtn.addEventListener('click', function () { openQuizBattleRanking(); });
+ /* ZOOM เข้า/ออก ในฉาก */
+ const applyQbZoom = function (mul) {
+ quizBattleUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, Math.min(PLAY_EMBED_USER_ZOOM_MAX, quizBattleUserZoomMul * mul));
+ try { draw(); } catch (e) { /* ignore */ }
+ };
+ const zoomInBtn = document.getElementById('qb-hud-zoom-in');
+ if (zoomInBtn) zoomInBtn.addEventListener('click', function () { applyQbZoom(1.2); });
+ const zoomOutBtn = document.getElementById('qb-hud-zoom-out');
+ if (zoomOutBtn) zoomOutBtn.addEventListener('click', function () { applyQbZoom(1 / 1.2); });
+ /* ปิด popup: ปุ่ม X / backdrop (data-qb-close) */
+ ['qb-myresult-popup', 'qb-ranking-popup', 'qb-completed-popup'].forEach(function (pid) {
+ const pop = document.getElementById(pid);
+ if (!pop) return;
+ pop.addEventListener('click', function (e) {
+ const t = e.target;
+ if (t && t.getAttribute && t.getAttribute('data-qb-close')) {
+ closeQuizBattlePopup(t.getAttribute('data-qb-close'));
+ }
+ });
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') { closeQuizBattlePopup('myresult'); closeQuizBattlePopup('ranking'); closeQuizBattlePopup('completed'); }
+ });
+ })();
+ (function wireQuizBattleMcqModal() {
+ const ov = document.getElementById('quiz-battle-mcq-overlay');
+ if (!ov) return;
+ ov.addEventListener('click', (e) => {
+ const t = e.target;
+ if (!t) return;
+ const choice = t.closest ? t.closest('.quiz-battle-choice') : null;
+ if (choice) {
+ const idx = parseInt(choice.getAttribute('data-idx'), 10);
+ if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx);
+ return;
+ }
+ if (t.id === 'quiz-battle-mcq-close') {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ return;
+ }
+ if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) {
+ quizBattleModalCompId = null;
+ hideQuizBattleMcqModal();
+ }
+ });
+ })();
+ (function wireQuizCarryGrabButton() {
+ const btn = document.getElementById('quiz-carry-grab-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ if (!isQuizCarry() || !mapData || isChatFocused()) return;
+ if (!btn.classList.contains('quiz-carry-grab-btn--active')) return;
+ ev.preventDefault();
+ if (myId == null) return;
+ tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
+ });
+ })();
+
+ (function wireGauntletCrownJumpButton() {
+ const btn = document.getElementById('gauntlet-crown-jump-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (isChatFocused()) return;
+ /* MG5 jumper: click = queue jump (mirror keyboard Space/W/↑) */
+ if (mapData && isJumpSurviveMissionHudActivePlay()) { jumpSurviveJumpQueued = true; return; }
+ if (!mapData || !isGauntlet()) return;
+ tryRequestGauntletJumpPlay();
+ });
+ })();
+
+ (function wireStackTowerDropButton() {
+ const btn = document.getElementById('stack-tower-drop-btn');
+ if (!btn) return;
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ if (!mapData || !isStack() || isChatFocused()) return;
+ if (!btn.classList.contains('stack-tower-drop-btn--active')) return;
+ tryHumanStackDrop();
+ });
+ })();
+
+ (function wireQuizQuestionMissionJoystick() {
+ const base = document.getElementById('quiz-q-mission-joystick-base');
+ const bg = document.getElementById('quiz-q-mission-joystick-bg');
+ const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
+ if (!base) return;
+ if (bg) {
+ bg.src = questionMissionAssetUrl('btn-joystick-1.png');
+ bg.dataset.joySkin = 'question';
+ bg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ if (knobImg) {
+ knobImg.src = questionMissionAssetUrl('btn-joystick-2.png');
+ knobImg.dataset.joySkin = 'question';
+ knobImg.onerror = function () {
+ this.onerror = null;
+ this.removeAttribute('src');
+ };
+ }
+ function endJoy(e) {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ try {
+ base.releasePointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ }
+ base.addEventListener('pointerdown', (e) => {
+ if (!(isQuizQuestionMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()) || isChatFocused()) return;
+ if (e.button != null && e.button !== 0) return;
+ e.preventDefault();
+ try {
+ base.setPointerCapture(e.pointerId);
+ } catch (_err) { /* ignore */ }
+ quizQuestionMissionJoyPointerId = e.pointerId;
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointermove', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ e.preventDefault();
+ quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
+ });
+ base.addEventListener('pointerup', endJoy);
+ base.addEventListener('pointercancel', endJoy);
+ base.addEventListener('lostpointercapture', (e) => {
+ if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
+ quizQuestionMissionJoyPointerId = null;
+ quizQuestionMissionJoystickResetVisual();
+ });
+ })();
+
+ (function wireGauntletCrownHowtoPrimary() {
+ function onHowtoPrimaryClick() {
+ if (isJumpSurviveMissionUiMapPlay()) {
+ if (jumpSurviveMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ if (!(humans.length === 1 || isMePlayHost())) return;
+ beginJumpSurviveMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateJumpSurviveMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isSpaceShooterMissionUiMapPlay()) {
+ if (spaceShooterMissionPhase !== 'howto') return;
+ const humansSs = quizCarryPregameHumanIds();
+ const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSs === 1) {
+ if (!(humansSs.length === 1 || isMePlayHost())) return;
+ beginSpaceShooterMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSs = String(myId);
+ const nextSs = !gauntletCrownLobbyReadyMap[sidSs];
+ gauntletCrownLobbyReadyMap[sidSs] = nextSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSs });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySs = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySs) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSs = String(myId);
+ const nextHostSs = !gauntletCrownLobbyReadyMap[sidHostSs];
+ gauntletCrownLobbyReadyMap[sidHostSs] = nextHostSs;
+ updateSpaceShooterMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSs });
+ return;
+ }
+ if (isQuizQuestionMissionUiMapPlay()) {
+ if (quizQuestionMissionPhase !== 'howto') return;
+ const humans = quizCarryPregameHumanIds();
+ const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayers === 1) {
+ beginQuizQuestionMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ return;
+ }
+ if (myId == null) return;
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidH = String(myId);
+ const nextH = !gauntletCrownLobbyReadyMap[sidH];
+ gauntletCrownLobbyReadyMap[sidH] = nextH;
+ updateQuizQuestionMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
+ return;
+ }
+ if (isStackTowerMissionUiMapPlay()) {
+ if (stackTowerMissionPhase !== 'howto') return;
+ const humansSt = quizCarryPregameHumanIds();
+ const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers());
+ if (totPlayersSt === 1) {
+ if (!(humansSt.length === 1 || isMePlayHost())) return;
+ beginStackTowerMissionCountdownThenRun();
+ return;
+ }
+ if (!isMePlayHost()) {
+ if (myId == null) return;
+ const sidSt = String(myId);
+ const nextSt = !gauntletCrownLobbyReadyMap[sidSt];
+ gauntletCrownLobbyReadyMap[sidSt] = nextSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt });
+ return;
+ }
+ if (myId == null) return;
+ const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReadySt) {
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
+ return;
+ }
+ const sidHostSt = String(myId);
+ const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt];
+ gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt;
+ updateStackTowerMissionHowtoHud();
+ if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt });
+ return;
+ }
+ if (!usesCrownLobbyShellPlay()) return;
+ if (gauntletCrownPregamePhase !== 'howto') return;
+ if (myId == null || !isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
+ if (humansReady) {
+ if (humans.length === 1 && isMePlayHost()) {
+ beginGauntletCrownCountdownThenRun();
+ return;
+ }
+ socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
+ if (!r || !r.ok) { /* ignore */ }
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !gauntletCrownLobbyReadyMap[sid];
+ gauntletCrownLobbyReadyMap[sid] = next;
+ updateGauntletCrownHowtoHud();
+ socket.emit('gauntlet-crown-lobby-ready', { ready: next });
+ }
+ ['btn-gch-ready', 'btn-gch-ready-mg2'].forEach((id) => {
+ const btn = document.getElementById(id);
+ if (btn) btn.addEventListener('click', onHowtoPrimaryClick);
+ });
+ })();
+
+ (function wireQuizCarryPregameOverlay() {
+ const primary = document.getElementById('quiz-carry-pregame-primary');
+ if (!primary) return;
+ primary.addEventListener('click', () => {
+ if (!quizCarryPregameActive || myId == null) return;
+ if (!isMePlayHost()) return;
+ const humans = quizCarryPregameHumanIds();
+ const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
+ if (humansReady) {
+ /* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */
+ if (humans.length === 1 && isMePlayHost()) {
+ endQuizCarryEmbedPregameAndStart();
+ return;
+ }
+ socket.emit('quiz-carry-lobby-start', {}, (r) => {
+ if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false);
+ });
+ return;
+ }
+ const sid = String(myId);
+ const next = !quizCarryLobbyReadyMap[sid];
+ quizCarryLobbyReadyMap[sid] = next;
+ updateQuizCarryPregameHud();
+ socket.emit('quiz-carry-lobby-ready', { ready: next });
+ });
+ })();
+ window.addEventListener('beforeunload', () => { try { saveQuizBattleProgress(); } catch (e) { /* ignore */ } socket.emit('leave-space'); });
+ window.addEventListener('resize', () => {
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ });
+ /** แคนวาส flex ขยายทีหลัง (จอใหญ่ / editor iframe) — ResizeObserver จับได้ดีกว่า resize อย่างเดียว */
+ (function setupPlayCanvasStackResizeObserver() {
+ const stack = document.getElementById('play-canvas-stack');
+ if (!stack || typeof ResizeObserver === 'undefined') return;
+ let roRaf = null;
+ const run = () => {
+ roRaf = null;
+ if (!mapData || !canvas) return;
+ resizeCanvas();
+ draw();
+ };
+ const ro = new ResizeObserver(() => {
+ if (roRaf) cancelAnimationFrame(roRaf);
+ roRaf = requestAnimationFrame(run);
+ });
+ ro.observe(stack);
+ const wrap = stack.closest('.game-wrap');
+ if (wrap) ro.observe(wrap);
+ const stageEl = document.getElementById('play-canvas-stage');
+ if (stageEl) ro.observe(stageEl);
+ })();
+})();
diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html
index e777d73..04a9e80 100644
--- a/www/html/Game/public/play.html
+++ b/www/html/Game/public/play.html
@@ -3877,17 +3877,17 @@
/* ====== Minigame-2 mno9kb07 — HUD แนวนอน (mg2-score-bar) fix 1920×1080 ====== */
/* selector ต้องชนะ .play-cyber-hud--quiz-tf-mock ทั่วไป (score-bar แนวตั้ง 156×433 + overflow:hidden) */
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) {
display: block;
padding: 0 !important;
grid-template: none !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"].play-cyber-hud--gauntlet-crown-strip {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]).play-cyber-hud--gauntlet-crown-strip {
display: block !important;
grid-template: none !important;
padding: 0 !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-self {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-self {
position: absolute !important;
left: 1729px !important;
top: 21px !important;
@@ -3899,12 +3899,12 @@
align-self: unset !important;
margin: 0 !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-profile-frame {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-profile-frame {
background: none !important;
background-image: none !important;
overflow: visible !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-portrait-clip {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-portrait-clip {
display: block !important;
position: absolute !important;
left: 49px !important;
@@ -3918,7 +3918,7 @@
z-index: 1 !important;
pointer-events: none !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-profile-frame .play-cyber-portrait-img {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-profile-frame .play-cyber-portrait-img {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
@@ -3933,23 +3933,23 @@
background: transparent !important;
z-index: 1 !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-profile-frame-overlay {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-profile-frame-overlay {
display: block !important;
z-index: 0 !important; /* Avartar.png กรอบ (พื้นกลางทึบ) อยู่ "หลัง" หน้า portrait (clip z1) ไม่งั้นกรอบทับหน้า */
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"].play-cyber-hud--gauntlet-crown-strip {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]).play-cyber-hud--gauntlet-crown-strip {
display: block;
grid-template: none;
padding: 0;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-corruption,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-center-stack,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-hint,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-preview-line {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-corruption,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-center-stack,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-hint,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-preview-line {
display: none !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-hud-mock-stage .play-cyber-scoreboard,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-scoreboard.play-cyber-scoreboard--mg2-mock {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-hud-mock-stage .play-cyber-scoreboard,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-scoreboard.play-cyber-scoreboard--mg2-mock {
position: absolute !important;
left: 50px !important;
top: 30px !important;
@@ -3967,8 +3967,8 @@
box-shadow: none !important;
overflow: visible !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] #play-cyber-crown-score-head,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-crown-score-head {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) #play-cyber-crown-score-head,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-crown-score-head {
display: block !important;
flex: 0 0 144px !important;
width: 144px !important;
@@ -3980,13 +3980,13 @@
overflow: visible !important;
border: none !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] #play-cyber-crown-score-head.is-hidden {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) #play-cyber-crown-score-head.is-hidden {
display: block !important;
height: 71px !important;
min-height: 71px !important;
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-crown-score-head img,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .mg2-score-label {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-crown-score-head img,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .mg2-score-label {
display: block !important;
width: 144px !important;
height: 71px !important;
@@ -3994,8 +3994,8 @@
object-fit: contain;
transform: translateY(11px);
}
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-hud-mock-stage .play-cyber-score-list,
- html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-score-list.play-cyber-score-list--mg2-mock {
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-hud-mock-stage .play-cyber-score-list,
+ html #play-cyber-hud.play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-score-list.play-cyber-score-list--mg2-mock {
position: static !important;
left: auto !important;
top: auto !important;
@@ -4009,7 +4009,7 @@
padding: 0 !important;
overflow: visible !important;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-score-row--mg2-mock {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-score-row--mg2-mock {
display: block;
width: 70px;
flex: 0 0 70px;
@@ -4017,11 +4017,11 @@
min-height: 0;
max-height: none;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-player {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-player {
width: 70px;
text-align: center;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-name {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-name {
font-family: 'NotoSansThai', sans-serif;
font-weight: 700;
font-size: 11px;
@@ -4034,13 +4034,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-av-wrap {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-av-wrap {
position: relative;
width: 70px;
height: 70px;
margin: 0 auto 4px;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-av-wrap::after {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-av-wrap::after {
content: '';
position: absolute;
inset: 0;
@@ -4048,14 +4048,14 @@
pointer-events: none;
background: var(--mmh-score-avatar, url('/Game/img/gauntlet-assets/score-avartar.png')) no-repeat center / contain;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-av-clip {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-av-clip {
position: absolute;
inset: 6px; /* นั่งในขอบ cyan ของ frame (avatar บนสุด z2) */
z-index: 2;
overflow: hidden;
border-radius: 6px;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-score-av--mg2 {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-score-av--mg2 {
position: absolute;
inset: 0;
width: 100%;
@@ -4068,7 +4068,7 @@
transform: scale(0.73) translateY(-3px);
transform-origin: center center;
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-mg2-score {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-mg2-score {
display: block;
font-family: 'NotoSansThai', sans-serif;
font-weight: 900;
@@ -4077,7 +4077,7 @@
line-height: 1;
text-shadow: 0 0 8px rgba(120, 220, 255, 0.6);
}
- #play-cyber-hud.play-cyber-hud--quiz-tf-mock[data-mission-mock-skin="gauntlet_crown"] .play-cyber-score-row--mg2-mock.is-out .play-cyber-mg2-score {
+ #play-cyber-hud.play-cyber-hud--quiz-tf-mock:is([data-mission-mock-skin="gauntlet_crown"], [data-mission-mock-skin="quiz_carry"]) .play-cyber-score-row--mg2-mock.is-out .play-cyber-mg2-score {
color: #ff8a8a;
text-shadow: 0 0 8px rgba(255, 90, 90, 0.55);
}
@@ -5152,7 +5152,7 @@
-
+
v —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SCORE
+
+
+
+
+
+
+
TIME
+
:
+
0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
RAM ERROR · CORRUPTING · DELETE INITIATED · 01001101 01110011 01100101 01100011
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PLAYERS : 1/50
+
SCORE : 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ −
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ประลองความรู้ คำศัพท์ในกระบวนการยุติธรรม — How to play
+
+
+
+
+
+
+
+
+
คำถาม · Question
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
รับหลักฐาน · Receive evidence
+
+
+
+
+
+
+
เกมจบ
+
+
+
กลับห้อง (ล็อบบี้) · Back to room lobby
+
+
+
+
+
+
+
+
สรุปผลภารกิจ
+
+
+
+
+
+
โบนัสพิเศษ
+
+
+
+
ภารกิจสำเร็จ
+
+
+
+
+
+
รับหลักฐาน · Done
+
+
+
+
+
+
+
+
+
+
+
+
คำถามพิเศษ · Special Quiz
+
ข้อ 1 / 1
+
+
20
+
+
+
+
+
สมาชิกในรอบนี้ — ต้องตอบถูกครบทุกคน
+
+
+
+
+
ได้รับการ์ดพิเศษ!
+
+
+
+
เล่นต่อ ▶
+
+
+
+
+
+
+
+ v —
+
+
+
+