diff --git a/www/html/Admin/private/store.json b/www/html/Admin/private/store.json
index ba8540f..fb3b44d 100644
--- a/www/html/Admin/private/store.json
+++ b/www/html/Admin/private/store.json
@@ -51,9 +51,9 @@
"providerUserId": "p_1775109142385_wq7wfy1p32j",
"notes": "auto: player-coins",
"blocked": false,
- "coins": 2860,
+ "coins": 2890,
"createdAt": "2026-04-02T05:52:21+00:00",
- "updatedAt": "2026-07-01T14:37:22+00:00",
+ "updatedAt": "2026-07-02T03:30:08+00:00",
"daily": {
"anchorMs": 1781197200000,
"claimedDays": [
@@ -67,11 +67,11 @@
],
"lockUntilMs": 0
},
- "score": 786,
+ "score": 816,
"scoreByCase": {
"1": 26,
- "10": 914,
- "8": 296,
+ "10": 924,
+ "8": 306,
"11": 20,
"13": 364,
"14": 30,
@@ -83,7 +83,7 @@
"7": 14,
"3": 150,
"6": 44,
- "2": 224
+ "2": 234
},
"lbName": "บอท7",
"achievements": {
@@ -1072,15 +1072,15 @@
"providerUserId": "p_1782802776912_g9ua6h1w5dj",
"notes": "auto: achievements",
"blocked": false,
- "coins": 40,
+ "coins": 56,
"achievements": {
- "d1_minigame_solver": 4
+ "d1_minigame_solver": 6
},
"createdAt": "2026-07-01T06:43:44+00:00",
- "updatedAt": "2026-07-01T06:48:17+00:00",
- "score": 40,
+ "updatedAt": "2026-07-01T16:08:23+00:00",
+ "score": 56,
"scoreByCase": {
- "1": 40
+ "1": 56
},
"lbName": "DevTester"
},
@@ -1121,6 +1121,46 @@
],
"lockUntilMs": 1782925200000
}
+ },
+ {
+ "id": "998017fcbe328e93ad04b2bd",
+ "email": "",
+ "displayName": "MONE",
+ "loginType": "guest",
+ "providerUserId": "p_1782921490642_wkx6gl2hwtn",
+ "notes": "auto: game-award",
+ "blocked": false,
+ "coins": 24,
+ "score": 24,
+ "scoreByCase": {
+ "1": 24
+ },
+ "lbName": "MONE",
+ "createdAt": "2026-07-01T16:01:03+00:00",
+ "updatedAt": "2026-07-01T16:08:23+00:00",
+ "achievements": {
+ "d1_minigame_solver": 3
+ }
+ },
+ {
+ "id": "cc3c865981aa4d36998c8645",
+ "email": "",
+ "displayName": "ผู้เล่น",
+ "loginType": "guest",
+ "providerUserId": "p_1782921488461_e17kxhzmrl8",
+ "notes": "auto: game-award",
+ "blocked": false,
+ "coins": 18,
+ "score": 18,
+ "scoreByCase": {
+ "1": 18
+ },
+ "lbName": "ผู้เล่น",
+ "createdAt": "2026-07-01T16:01:03+00:00",
+ "updatedAt": "2026-07-01T16:08:23+00:00",
+ "achievements": {
+ "d1_minigame_solver": 1
+ }
}
]
}
\ No newline at end of file
diff --git a/www/html/Game/data/game-timing.json b/www/html/Game/data/game-timing.json
index 410f230..c94980d 100644
--- a/www/html/Game/data/game-timing.json
+++ b/www/html/Game/data/game-timing.json
@@ -97,7 +97,7 @@
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png",
"balloonBossBalloonsPerPlayer": 3,
"forcedMinigameKeys": [
- "gauntlet",
+ "quiz_carry",
"quiz",
"balloon_boss"
],
diff --git a/www/html/Game/public/audio/button.wav b/www/html/Game/public/audio/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/button.wav differ
diff --git a/www/html/Game/public/audio/lobby/bgm.mp3 b/www/html/Game/public/audio/lobby/bgm.mp3
new file mode 100644
index 0000000..2e7470a
Binary files /dev/null and b/www/html/Game/public/audio/lobby/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg1/bgm.mp3 b/www/html/Game/public/audio/mg1/bgm.mp3
new file mode 100644
index 0000000..ee9b6d0
Binary files /dev/null and b/www/html/Game/public/audio/mg1/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg1/button.wav b/www/html/Game/public/audio/mg1/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg1/button.wav differ
diff --git a/www/html/Game/public/audio/mg1/correct.mp3 b/www/html/Game/public/audio/mg1/correct.mp3
new file mode 100644
index 0000000..07a23f5
Binary files /dev/null and b/www/html/Game/public/audio/mg1/correct.mp3 differ
diff --git a/www/html/Game/public/audio/mg1/endgame.mp3 b/www/html/Game/public/audio/mg1/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg1/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg1/time.mp3 b/www/html/Game/public/audio/mg1/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg1/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg1/wrong.mp3 b/www/html/Game/public/audio/mg1/wrong.mp3
new file mode 100644
index 0000000..afc0304
Binary files /dev/null and b/www/html/Game/public/audio/mg1/wrong.mp3 differ
diff --git a/www/html/Game/public/audio/mg2/bgm.mp3 b/www/html/Game/public/audio/mg2/bgm.mp3
new file mode 100644
index 0000000..9369d05
Binary files /dev/null and b/www/html/Game/public/audio/mg2/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg2/button.wav b/www/html/Game/public/audio/mg2/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg2/button.wav differ
diff --git a/www/html/Game/public/audio/mg2/gameover.mp3 b/www/html/Game/public/audio/mg2/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg2/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg2/jump.mp3 b/www/html/Game/public/audio/mg2/jump.mp3
new file mode 100644
index 0000000..5ac13d6
Binary files /dev/null and b/www/html/Game/public/audio/mg2/jump.mp3 differ
diff --git a/www/html/Game/public/audio/mg2/time.mp3 b/www/html/Game/public/audio/mg2/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg2/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg2/winner.mp3 b/www/html/Game/public/audio/mg2/winner.mp3
new file mode 100644
index 0000000..70a4427
Binary files /dev/null and b/www/html/Game/public/audio/mg2/winner.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/bgm.mp3 b/www/html/Game/public/audio/mg3/bgm.mp3
new file mode 100644
index 0000000..3a5a2f6
Binary files /dev/null and b/www/html/Game/public/audio/mg3/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/button.wav b/www/html/Game/public/audio/mg3/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg3/button.wav differ
diff --git a/www/html/Game/public/audio/mg3/drop-clear.mp3 b/www/html/Game/public/audio/mg3/drop-clear.mp3
new file mode 100644
index 0000000..49cbfca
Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-clear.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/drop-fail.mp3 b/www/html/Game/public/audio/mg3/drop-fail.mp3
new file mode 100644
index 0000000..f6485f6
Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-fail.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/drop-hard.mp3 b/www/html/Game/public/audio/mg3/drop-hard.mp3
new file mode 100644
index 0000000..b5abfdc
Binary files /dev/null and b/www/html/Game/public/audio/mg3/drop-hard.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/endgame.mp3 b/www/html/Game/public/audio/mg3/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg3/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/gameover.mp3 b/www/html/Game/public/audio/mg3/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg3/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/time.mp3 b/www/html/Game/public/audio/mg3/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg3/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg3/timeup.mp3 b/www/html/Game/public/audio/mg3/timeup.mp3
new file mode 100644
index 0000000..d90b15e
Binary files /dev/null and b/www/html/Game/public/audio/mg3/timeup.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/bgm.mp3 b/www/html/Game/public/audio/mg4/bgm.mp3
new file mode 100644
index 0000000..2e7470a
Binary files /dev/null and b/www/html/Game/public/audio/mg4/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/button.wav b/www/html/Game/public/audio/mg4/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg4/button.wav differ
diff --git a/www/html/Game/public/audio/mg4/correct.mp3 b/www/html/Game/public/audio/mg4/correct.mp3
new file mode 100644
index 0000000..07a23f5
Binary files /dev/null and b/www/html/Game/public/audio/mg4/correct.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/endgame.mp3 b/www/html/Game/public/audio/mg4/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg4/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/gameover.mp3 b/www/html/Game/public/audio/mg4/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg4/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/time.mp3 b/www/html/Game/public/audio/mg4/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg4/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/timeup.mp3 b/www/html/Game/public/audio/mg4/timeup.mp3
new file mode 100644
index 0000000..d90b15e
Binary files /dev/null and b/www/html/Game/public/audio/mg4/timeup.mp3 differ
diff --git a/www/html/Game/public/audio/mg4/wrong.mp3 b/www/html/Game/public/audio/mg4/wrong.mp3
new file mode 100644
index 0000000..afc0304
Binary files /dev/null and b/www/html/Game/public/audio/mg4/wrong.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/bgm.mp3 b/www/html/Game/public/audio/mg5/bgm.mp3
new file mode 100644
index 0000000..e2ac7a9
Binary files /dev/null and b/www/html/Game/public/audio/mg5/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/button.wav b/www/html/Game/public/audio/mg5/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg5/button.wav differ
diff --git a/www/html/Game/public/audio/mg5/endgame.mp3 b/www/html/Game/public/audio/mg5/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg5/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/gameover.mp3 b/www/html/Game/public/audio/mg5/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg5/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/jump.mp3 b/www/html/Game/public/audio/mg5/jump.mp3
new file mode 100644
index 0000000..5ac13d6
Binary files /dev/null and b/www/html/Game/public/audio/mg5/jump.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/time.mp3 b/www/html/Game/public/audio/mg5/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg5/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg5/timeup.mp3 b/www/html/Game/public/audio/mg5/timeup.mp3
new file mode 100644
index 0000000..d90b15e
Binary files /dev/null and b/www/html/Game/public/audio/mg5/timeup.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/bgm.mp3 b/www/html/Game/public/audio/mg6/bgm.mp3
new file mode 100644
index 0000000..9306a52
Binary files /dev/null and b/www/html/Game/public/audio/mg6/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/bomb.mp3 b/www/html/Game/public/audio/mg6/bomb.mp3
new file mode 100644
index 0000000..2b9a15e
Binary files /dev/null and b/www/html/Game/public/audio/mg6/bomb.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/button.wav b/www/html/Game/public/audio/mg6/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg6/button.wav differ
diff --git a/www/html/Game/public/audio/mg6/crash.mp3 b/www/html/Game/public/audio/mg6/crash.mp3
new file mode 100644
index 0000000..6fda721
Binary files /dev/null and b/www/html/Game/public/audio/mg6/crash.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/endgame.mp3 b/www/html/Game/public/audio/mg6/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg6/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/gameover.mp3 b/www/html/Game/public/audio/mg6/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg6/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/laser.mp3 b/www/html/Game/public/audio/mg6/laser.mp3
new file mode 100644
index 0000000..9a0ea7e
Binary files /dev/null and b/www/html/Game/public/audio/mg6/laser.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/time.mp3 b/www/html/Game/public/audio/mg6/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg6/time.mp3 differ
diff --git a/www/html/Game/public/audio/mg6/timeup.mp3 b/www/html/Game/public/audio/mg6/timeup.mp3
new file mode 100644
index 0000000..d90b15e
Binary files /dev/null and b/www/html/Game/public/audio/mg6/timeup.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/bgm.mp3 b/www/html/Game/public/audio/mg7/bgm.mp3
new file mode 100644
index 0000000..f57afc2
Binary files /dev/null and b/www/html/Game/public/audio/mg7/bgm.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/boss-elim.mp3 b/www/html/Game/public/audio/mg7/boss-elim.mp3
new file mode 100644
index 0000000..ddd2456
Binary files /dev/null and b/www/html/Game/public/audio/mg7/boss-elim.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/button.wav b/www/html/Game/public/audio/mg7/button.wav
new file mode 100644
index 0000000..16cf8dd
Binary files /dev/null and b/www/html/Game/public/audio/mg7/button.wav differ
diff --git a/www/html/Game/public/audio/mg7/endgame.mp3 b/www/html/Game/public/audio/mg7/endgame.mp3
new file mode 100644
index 0000000..093b5ec
Binary files /dev/null and b/www/html/Game/public/audio/mg7/endgame.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/gameover.mp3 b/www/html/Game/public/audio/mg7/gameover.mp3
new file mode 100644
index 0000000..7a268ed
Binary files /dev/null and b/www/html/Game/public/audio/mg7/gameover.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/laser.mp3 b/www/html/Game/public/audio/mg7/laser.mp3
new file mode 100644
index 0000000..cb468b0
Binary files /dev/null and b/www/html/Game/public/audio/mg7/laser.mp3 differ
diff --git a/www/html/Game/public/audio/mg7/time.mp3 b/www/html/Game/public/audio/mg7/time.mp3
new file mode 100644
index 0000000..f6c6e22
Binary files /dev/null and b/www/html/Game/public/audio/mg7/time.mp3 differ
diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js
index 62ecf91..3423937 100644
--- a/www/html/Game/public/js/play.js
+++ b/www/html/Game/public/js/play.js
@@ -4136,6 +4136,7 @@
function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) {
const urls = playSpaceShooterAsteroidSpriteUrls;
if (!urls || urls.length < 2) return;
+ if (window.jdSound) { try { window.jdSound.playSfx('bomb', 'mg6'); } catch (e) { /* ignore */ } }
spaceShooterAsteroidExplosions.push({
x: worldX,
y: worldY,
@@ -4296,6 +4297,7 @@
}
}
if (!mine) return;
+ if (window.jdSound) { try { window.jdSound.playSfx(mine.right ? 'correct' : 'wrong', 'mg1'); } catch (e) { /* ignore */ } }
el.classList.remove('is-hidden');
const botExtra = playBotsEnabled() && (botRight + botWrong > 0)
? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
@@ -4455,6 +4457,7 @@
const scoreEl = document.getElementById('quiz-map-q-feedback-score');
if (!panel || !icon || panel.classList.contains('is-hidden')) return;
clearQuizMapQuestionCarryFeedback();
+ if (window.jdSound) { try { window.jdSound.playSfx(isCorrect ? 'correct' : 'wrong', 'mg4'); } catch (e) { /* ignore */ } }
icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
icon.classList.remove('is-hidden');
if (isCorrect && scoreWrap && scoreEl) {
@@ -4905,6 +4908,8 @@
function setCountdown321QuestionAssetGraphic(numEl, n) {
if (!numEl) return;
const d = Math.max(1, Math.min(3, n));
+ /* เสียงนับถอยหลัง (เล่นครั้งเดียวต่อเลข กัน re-render ยิงซ้ำ) */
+ if (window.jdSound && window.__jdLastCountN !== d) { window.__jdLastCountN = d; try { window.jdSound.playSfx('time'); } catch (e) { /* ignore */ } }
if (String(numEl.tagName || '').toUpperCase() === 'IMG') {
numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png';
numEl.alt = String(d);
@@ -6500,6 +6505,24 @@
}
function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
+ /* ===== Sound (jdSound) — scene + end-sfx helpers ===== */
+ var JD_SCENE_MAP = { quiz: 'mg1', gauntlet: 'mg2', stack: 'mg3', quiz_carry: 'mg4', jump_survive: 'mg5', space_shooter: 'mg6', balloon_boss: 'mg7' };
+ function jdPlayScene() { var gt = mapData && mapData.gameType; return (gt && JD_SCENE_MAP[gt]) || 'mg1'; }
+ /* เล่นเสียงจบเกมจากชื่อไฟล์แฟลชผล (complete/victory=ชนะ · timeup=หมดเวลา · gameover=แพ้) + หยุด BGM */
+ function jdEndSfxFromFile(f) {
+ if (!window.jdSound) return;
+ f = String(f || ''); var s = jdPlayScene();
+ if (f.indexOf('victory') >= 0 || f.indexOf('complete') >= 0) window.jdSound.playSfx('win', s);
+ else if (f.indexOf('timeup') >= 0) window.jdSound.playSfx('timeup', s);
+ else if (f.indexOf('gameover') >= 0) window.jdSound.playSfx('lose', s);
+ try { window.jdSound.stopBgm(); } catch (e) { /* ignore */ }
+ }
+ /* เสียงคลิกปุ่ม (เฉพาะ element ที่เป็นปุ่มจริง) */
+ document.addEventListener('click', function (e) {
+ if (!window.jdSound) return;
+ var t = (e.target && e.target.closest) ? e.target.closest('button, .btn, [role="button"], [class*="btn-"]') : null;
+ if (t) { try { window.jdSound.playSfx('button', jdPlayScene()); } catch (err) { /* ignore */ } }
+ }, true);
function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
function isGauntletFaceRightMapMno9kb07() {
if (!isGauntlet()) return false;
@@ -7306,6 +7329,7 @@
if (!mission || !ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; }
const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); };
imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); };
+ jdEndSfxFromFile(file);
imgEl.src = gcmMissionMockAssetUrl(mission, file);
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
@@ -7339,6 +7363,7 @@
imgEl.onerror = null;
finishToGcm();
};
+ jdEndSfxFromFile(file);
imgEl.src = url;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
@@ -7381,6 +7406,7 @@
imgEl.onerror = null;
onDone();
};
+ jdEndSfxFromFile(file);
imgEl.src = jumperAssetUrl(file);
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
@@ -7425,6 +7451,7 @@
imgEl.onerror = null;
onDone();
};
+ jdEndSfxFromFile(file);
imgEl.src = violentCrimeAssetUrl(file);
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
@@ -7468,12 +7495,14 @@
};
const showOneFlash = function (url, onDone) {
imgEl.onerror = function () { imgEl.onerror = null; onDone(); };
+ jdEndSfxFromFile(url);
imgEl.src = url;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS);
};
if (endReason === 'victory') {
+ if (window.jdSound) { try { window.jdSound.playSfx('boss-elim', 'mg7'); } catch (e) { /* ignore */ } }
/* ฆ่าบอส HP=0 → end-victory-2.png (MegaVirus) */
showOneFlash(megaVirusAssetUrl('end-victory-2.png'), finishToGcm);
} else if (endReason === 'time') {
@@ -11293,6 +11322,7 @@
if ((meGauntletJumpTicks || 0) > 0 || isMeGauntletJumpAnimActivePlay()) return false;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
+ if (window.jdSound) window.jdSound.playSfx('jump', 'mg2');
/* เริ่มอาร์คกระโดด local ทันที (instant feedback) — ยาวเท่าจังหวะกระโดดของ server (jumpTicks×tickMs)
เพื่อใ��้เห็นโค้งขึ้น-ลงเต็ม แม้ server จะตัด jumpTicks=0 กลางคันตอนข้ามสำเร็จ */
meGauntletJumpAnimDur = Math.max(360, (gauntletRuntimeJumpTicks || 6) * (gauntletRuntimeTickMs || 220));
@@ -12511,6 +12541,7 @@
const m = stackMini;
const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat();
const actorHeavy = !!m.pendingDropHeavy;
+ if (window.jdSound) { try { window.jdSound.playSfx(hit.miss ? 'drop-fail' : (actorHeavy ? 'drop-hard' : 'drop-clear'), 'mg3'); } catch (e) { /* ignore */ } }
const actorTheme = m.pendingDropTheme || null; /* remote drop = สีจาก host; local = null (resolve เอง) */
const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay();
/* release = จุดที่ server ปล่อย (landOffsetTiles) → ร่วง "ตรง" ไม่ไถลเฉียง (เฟส server-time sync แล้ว จุดที่เห็น≈จุดนี้ → teleport เหลือนิดเดียว ถูกบังด้วยการเริ่มร่วง)
@@ -17323,6 +17354,7 @@
/* broadcast กระสุนให้ทุกคนเห็นตรงกัน */
try {
if (socket && myId != null) socket.emit('space-shooter-shot', { x: shotX, y: shotY, vy: -580, ownerId: myId, t0: serverNow() });
+ if (window.jdSound) window.jdSound.playSfx('laser', 'mg6');
} catch (eS) { /* ignore */ }
}
@@ -18408,6 +18440,7 @@
try {
if (socket && myId != null) {
socket.emit('balloon-boss-shot', { sx: shotSx, sy: shotSy, vx, vy, ownerId: myId, t0: serverNow() });
+ if (window.jdSound) window.jdSound.playSfx('laser', 'mg7');
}
} catch (eS) { /* ignore */ }
/* สเปก MG7: ยิง 1 ครั้ง = −5 คะแนน (ต่ำสุด 0 ไม่ติดลบ) */
@@ -19694,6 +19727,7 @@
const jq = jumpSurviveJumpQueued;
jumpSurviveJumpQueued = false;
+ if (jq && jumpSurviveOnGround && window.jdSound) { try { window.jdSound.playSfx('jump', 'mg5'); } catch (e) { /* ignore */ } }
/* virtual joystick (touch) — แกน x ป้อนซ้าย/ขวาเหมือนคีย์ A/D (deadzone จัดการในตัว update แล้ว) */
const joyX = isJumpSurviveMissionHudActivePlay() ? (Number(quizQuestionMissionJoyVecX) || 0) : 0;
@@ -21848,7 +21882,7 @@
voiceAnalyser.getByteFrequencyData(dataArray);
var sum = 0; for (var i = 0; i < dataArray.length; i++) sum += dataArray[i];
var level = Math.min(1, (sum / dataArray.length) / 60);
- if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); }
+ if (level > 0.08 && Date.now() - lastEmit > 80) { lastEmit = Date.now(); socket.emit('voice-activity', { level: level }); if (window.jdSound) window.jdSound.notifySpeaking(); }
}, 100);
} catch (e) { /* ignore */ }
}
@@ -26452,6 +26486,8 @@
}
function tick() {
if (!mapData) { playTickLastTs = 0; requestAnimationFrame(tick); return; }
+ /* BGM ต่อมินิเกม (idempotent — playBgm return เร็วถ้า scene เดิม) */
+ if (window.jdSound) { try { window.jdSound.playBgm(jdPlayScene()); } catch (e) { /* ignore */ } }
/* dt frame (วินาที) → playDtMul = dt*60 (clamp 0.05s = step ≤0.45 tile กัน tunneling) ใช้ทำ movement time-based */
{
const _pnow = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
diff --git a/www/html/Game/public/js/play.js.bak-sfx2 b/www/html/Game/public/js/play.js.bak-sfx2
new file mode 100644
index 0000000..f5136a1
--- /dev/null
+++ b/www/html/Game/public/js/play.js.bak-sfx2
@@ -0,0 +1,27205 @@
+(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';
+ /* Card 3 Ban — ผู้ถูกแบนรอบนี้: เข้ามาดูเพื่อนเล่นได้ แต่ควบคุมตัวละคร/ทำแอ็กชันไม่ได้ (รอบหน้าเล่นปกติ) */
+ const playBannedSpectator = params.get('banned') === '1';
+ /* Card 3 Ban (กรณีโหวตแบน "บอท") — slot ของ __pv_bot_N ที่ถูกแบน → ไม่ spawn ลงเล่นรอบนี้ (-1 = ไม่มี) */
+ const playBannedBotSlot = (() => { const v = parseInt(params.get('banBot'), 10); return Number.isFinite(v) && v >= 0 ? v : -1; })();
+ /* shield โปร่งใสคลุมทั้งจอ (z 200) กิน pointer/touch ทุกอย่าง → ปุ่มเกม(jump z59/drop z60/joystick/shoot)+canvas กดไม่ได้
+ แต่ต่ำกว่า modal (z 10000+) เลย special quiz / สรุปผล ยังกดได้ปกติ + ป้ายบอกสถานะด้านบน */
+ function setupBannedSpectatorUi() {
+ if (!playBannedSpectator || document.getElementById('banned-spectator-shield')) return;
+ const shield = document.createElement('div');
+ shield.id = 'banned-spectator-shield';
+ shield.style.cssText = 'position:fixed;inset:0;z-index:200;background:transparent;pointer-events:auto;cursor:not-allowed;touch-action:none;-webkit-tap-highlight-color:transparent';
+ ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'touchstart', 'touchend', 'touchmove', 'wheel', 'contextmenu'].forEach(function (ev) {
+ shield.addEventListener(ev, function (e) { e.stopPropagation(); e.preventDefault(); }, { passive: false });
+ });
+ document.body.appendChild(shield);
+ const banner = document.createElement('div');
+ banner.id = 'banned-spectator-banner';
+ banner.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:201;pointer-events:none;background:linear-gradient(180deg,#2a1430,#160a1d);border:1px solid rgba(247,118,142,.6);border-radius:14px;padding:10px 20px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,.55)';
+ banner.innerHTML = '
BAN CARD
'
+ + 'คุณถูกแบนรอบนี้ · กำลังดูเพื่อนเล่น
'
+ + 'รอบหน้ากลับมาเล่นได้ตามปกติ
';
+ document.body.appendChild(banner);
+ }
+ 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 */ }
+ }
+
+ /* #5 หน้าโหลดตอนเข้าเกม — กันเห็นจอเปล่า/asset แว่บก่อนฉากพร้อม. โชว์ทันที ซ่อนเมื่อ draw() เฟรมแรกมี mapData
+ (+ หน่วงเล็กน้อยให้ asset แรกโหลด) มี safety timeout กันค้าง. ไม่โชว์ใน editor embed preview */
+ let playLoadingHidden = false;
+ let playLoadingHideScheduled = false;
+ const PLAY_LOADING_MIN_MS = 450; /* โชว์อย่างน้อยเท่านี้ กันแว่บหาย (ไม่ทันเห็น) */
+ let playLoadingShownAt = (typeof performance !== 'undefined' ? performance.now() : Date.now());
+ function nowMsPlay() { return (typeof performance !== 'undefined' ? performance.now() : Date.now()); }
+ function hidePlayLoadingOverlay() {
+ if (playLoadingHidden) return;
+ const elapsed = nowMsPlay() - playLoadingShownAt;
+ if (elapsed < PLAY_LOADING_MIN_MS) { setTimeout(hidePlayLoadingOverlay, PLAY_LOADING_MIN_MS - elapsed); return; }
+ playLoadingHidden = true;
+ try {
+ const el = document.getElementById('play-loading-overlay');
+ if (el) { el.style.opacity = '0'; el.style.pointerEvents = 'none'; setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 340); }
+ } catch (_e) { /* ignore */ }
+ }
+ (function showPlayLoadingOverlay() {
+ /* editor embed preview: ไม่ต้องมี loading → เอา overlay static ออก */
+ if (previewMode && editorEmbedReturn) {
+ playLoadingHidden = true;
+ try { const pe = document.getElementById('play-loading-overlay'); if (pe && pe.parentNode) pe.parentNode.removeChild(pe); } catch (_e) {}
+ return;
+ }
+ try {
+ if (!document.body) return;
+ /* overlay เป็น static ใน play.html แล้ว — ถ้าไม่มี (html เก่า cache) ค่อย inject เสริม */
+ if (!document.getElementById('play-loading-overlay')) {
+ const css = document.createElement('style');
+ css.textContent = ''
+ + '#play-loading-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;align-items:center;'
+ + 'justify-content:center;gap:18px;background:radial-gradient(circle at 50% 42%,#10203c,#060b16 75%);transition:opacity .32s;}'
+ + '#play-loading-overlay .plo-spin{width:62px;height:62px;border-radius:50%;border:6px solid rgba(120,190,255,.18);'
+ + 'border-top-color:#5fd0ff;animation:ploSpin .85s linear infinite;}'
+ + '#play-loading-overlay .plo-text{color:#cfe8ff;font:600 19px system-ui,"Kanit",sans-serif;letter-spacing:.5px;text-shadow:0 2px 10px rgba(0,0,0,.5);}'
+ + '@keyframes ploSpin{to{transform:rotate(360deg);}}';
+ document.head.appendChild(css);
+ const ov = document.createElement('div');
+ ov.id = 'play-loading-overlay';
+ ov.innerHTML = 'กำลังโหลดเกม…
';
+ document.body.insertBefore(ov, document.body.firstChild);
+ }
+ /* safety กันค้าง — แต่ "อย่าซ่อนถ้าเกมยังไม่ render" (เน็ตช้า join นาน → ถ้าซ่อน 8 วิ = จอดำเปล่า ไม่รู้สถานะ)
+ เช็คทุก 1.5 วิ: เกมเริ่มวาดแล้ว (playLoadingHideScheduled) → ปล่อย path ปกติซ่อน · ยังไม่วาด+ช้า → โชว์ "เน็ตช้า (N วิ)" คง spinner · เกิน 30 วิ ค่อยซ่อนกันค้างถาวร */
+ const _ploT0 = Date.now();
+ const _ploCheck = function () {
+ if (playLoadingHidden || playLoadingHideScheduled) return; /* ซ่อนแล้ว/เกมเริ่มวาดแล้ว */
+ const waited = Date.now() - _ploT0;
+ if (waited > 30000) { hidePlayLoadingOverlay(); return; } /* hard limit กันค้างถาวร */
+ if (waited > 5000) {
+ const txt = document.querySelector('#play-loading-overlay .plo-text');
+ if (txt) txt.textContent = 'เน็ตช้า… กำลังโหลด (' + Math.floor(waited / 1000) + ' วิ)';
+ }
+ setTimeout(_ploCheck, 1500);
+ };
+ setTimeout(_ploCheck, 1500);
+ } catch (_e) { playLoadingHidden = true; }
+ })();
+ 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';
+ /* ใช้ map จริง (currentPlayMapId) ไม่ใช่ ?map= ใน URL — live play ไม่มี ?map= → เดิม false →
+ mission flow ไม่ทำงาน → MG4 จบหลังตอบ 1 ข้อ (bug #41). เป็นฟังก์ชันเพราะ map รู้ตอน join */
+ function quizCarryUseMissionSummaryOverlay() { return currentPlayMapId() === 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.5; /* ต้องตรงกับ server — miss เมื่อ overlap < 50% บล็อก */
+ /** จุดกลางแท่งที่วาง 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 = 34;
+ const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 8;
+
+ /* PRNG แบบ seed ได้ (mulberry32) — ใช้สุ่มสิ่งแวดล้อมมินิเกมแบบ deterministic
+ seed เดียวกัน (อิง liveBeginsAt ของ server) → ทุกเครื่องได้ลำดับสุ่มเดียวกัน = อุกาบาต/บอลลูนชุดเดียวกัน */
+ function makeSeededRng(seed) {
+ let t = (seed >>> 0) || 1;
+ return function () {
+ t = (t + 0x6D2B79F5) >>> 0;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+ /* seed ต่อ index ของ spawn — รวม anchor (liveBeginsAt) กับ index ให้ก้อนแต่ละลำดับสุ่มต่างกันแต่ตรงกันทุกเครื่อง */
+ function envSpawnSeed(anchorMs, index) {
+ return (((anchorMs >>> 0) ^ Math.imul((index | 0) + 1, 2654435761)) >>> 0);
+ }
+
+ 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;
+ }
+ }
+
+ const wasElim = !!me.spaceShooterEliminated;
+ if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy);
+ /* คิดการชนเฉพาะยานของเรา (เจ้าของตัดสินเอง) + บอท (host ตัดสิน) เท่านั้น
+ ผู้เล่นมนุษย์คนอื่นรอสถานะ eliminated/hits จาก move ของเจ้าตัว — กัน "ตายไม่พร้อมกัน"
+ (เดิมทุกเครื่องคิดชนให้ทุกยานด้วยตำแหน่งดาวเคราะห์ local ที่ต่างกัน → ตัดสินไม่ตรงกัน) */
+ others.forEach(function (o, id) {
+ if (!o) return;
+ /* host คิดการชนให้ "บอท" รวม takeover bot (id=socket-id, flag isSpaceShooterServerBot) — เดิมเช็คแค่ isPreviewBotId → ข้าม takeover bot = มันไม่มีวันตาย */
+ if (!isMePlayHost()) return;
+ if (!(isPreviewBotId(id) || o.isSpaceShooterServerBot)) return;
+ if (spaceShooterServerAuthActive()) {
+ /* server-auth (MG6 เกมจริง): host คิดการชนบอท (asteroid deterministic + ตำแหน่งบอทจาก server)
+ แล้ว "รายงาน" จำนวนชนให้ server ตัดสินตายจริง — ไม่ตัดสินตายเอง local (กันสถานะตายขัดกัน) */
+ const h0 = Math.max(0, Number(o.spaceShooterHits) || 0);
+ tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
+ const h1 = Math.max(0, Number(o.spaceShooterHits) || 0);
+ if (h1 > h0 && socket) {
+ try { socket.emit('space-shooter-bot-hit', { id, hits: h1 }); } catch (e) { /* ignore */ }
+ }
+ } else {
+ tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
+ }
+ });
+ /* เพิ่งตาย → broadcast ทันที ไม่รอ throttle 90ms ให้ทุกเครื่องเห็นพร้อมกัน */
+ if (!wasElim && me.spaceShooterEliminated && socket && myId != null) {
+ try {
+ spaceShooterLastMoveEmit = Date.now();
+ socket.emit('move', {
+ x: me.x, y: me.y, direction: me.direction,
+ spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
+ spaceShooterHits: Math.max(0, Number(me.spaceShooterHits) || 0),
+ spaceShooterEliminated: true,
+ });
+ } catch (eE) { /* ignore */ }
+ }
+ if (spaceShooterMissionEveryParticipantEliminatedPlay()) {
+ /* host ตัดสิน all_dead + broadcast คะแนน; non-host รอรับ space-shooter-over */
+ if (isMePlayHost()) triggerSpaceShooterOverPlay('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();
+ };
+ /* รายงาน score/survived ของเกมที่ client นับเอง (stack/jump_survive) → server ใช้จัดอันดับเหรียญ
+ (เดิมส่ง {} เปล่า → reportedMiniScore/Survived ไม่ถูกตั้ง → coin ranking MG3/MG5 เพี้ยน) */
+ let finishReport = {};
+ try {
+ if (isStack() && stackMini) finishReport = { score: Math.max(0, stackMini.score | 0), survived: true };
+ else if (isJumpSurvive()) finishReport = { score: 0, survived: !jumpSurviveEliminated };
+ } catch (eFR) { /* ignore */ }
+ if (socket && socket.connected) {
+ socket.emit('detective-minigame-finished', finishReport, 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 */ }
+ /* MG2: เข้าเส้นชัยแล้วแต่ยังมีคนวิ่งอยู่ → รอในฉาก (server จะพากลับ LobbyB พร้อมกันตอนทุกคนจบ) */
+ if (res && res.holdInScene) {
+ showGauntletFinishWaitOverlay();
+ return;
+ }
+ /* โชว์การ์ดหลักฐานที่เพิ่งได้ "ในฉากมินิเกม" ก่อนกลับ LobbyB */
+ if (res && typeof res.suspectIndex === 'number' && res.suspectIndex >= 0) {
+ showInSceneEvidenceReveal(res, go);
+ } else {
+ go();
+ }
+ });
+ } else {
+ go();
+ }
+ }
+
+ /** MG2: ป้าย "เข้าเส้นชัยแล้ว — รอผู้เล่นอื่น" ระหว่างรอคนที่ยังวิ่งอยู่ (จะกลับ LobbyB พร้อมกันตอนเกมจบ) */
+ function showGauntletFinishWaitOverlay() {
+ var ov = document.getElementById('gauntlet-finish-wait');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'gauntlet-finish-wait';
+ ov.style.cssText = 'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:radial-gradient(ellipse at center,rgba(10,16,32,.82),rgba(6,9,20,.95));backdrop-filter:blur(3px)';
+ ov.innerHTML =
+ '🏁 เข้าเส้นชัยแล้ว!
' +
+ 'รอผู้เล่นคนอื่นเข้าเส้นชัย...
' +
+ '• • •
';
+ document.body.appendChild(ov);
+ }
+ ov.style.display = 'flex';
+ }
+
+ /* แจ้ง host ว่าทำไม START ยังกดเริ่มไม่ได้ (เดิม reply ถูก ignore เงียบ → ปุ่มเหมือนเสีย)
+ ใช้ร่วม MG2/5/6/7 (crown lobby shell) — โชว์ "รอผู้เล่น x/N" หรือ "ยังมีคนไม่ Ready" แล้วซ่อนเอง */
+ function showCrownStartBlockedNoticePlay(r) {
+ if (!r) return;
+ let msg = r.error || 'ยังเริ่มไม่ได้';
+ if (r.waitingPlayers) msg = 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + (r.present || 0) + '/' + (r.total || 0) + ')';
+ let ov = document.getElementById('crown-start-notice');
+ if (!ov) {
+ ov = document.createElement('div');
+ ov.id = 'crown-start-notice';
+ ov.style.cssText = 'position:fixed;left:50%;top:14%;transform:translateX(-50%);z-index:100001;padding:12px 22px;border-radius:14px;background:rgba(20,12,16,.92);border:1px solid rgba(255,120,120,.5);color:#ffd9d9;font:700 18px/1.3 Kanit,system-ui,sans-serif;text-align:center;box-shadow:0 6px 24px rgba(0,0,0,.5);pointer-events:none';
+ document.body.appendChild(ov);
+ }
+ ov.textContent = '⏳ ' + msg;
+ ov.style.display = 'block';
+ clearTimeout(ov._hideT);
+ ov._hideT = setTimeout(function () { ov.style.display = 'none'; }, 2600);
+ }
+
+ /* ===== หน้าโชว์การ์ดหลักฐานในฉากมินิเกม (ก่อนกลับ 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';
+ /* scroll container + inner margin:auto → มือถือแนวตั้งที่เนื้อหาสูงเกินจอ เลื่อนถึงปุ่มได้ (เดิม justify center ไม่มี scroll → ปุ่มหลุดล่างจอ) */
+ ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(5,8,18,.9);display:flex;overflow-y:auto;-webkit-overflow-scrolling:touch;backdrop-filter:blur(3px);padding:16px 12px;box-sizing:border-box';
+ var inner = document.createElement('div');
+ inner.style.cssText = 'margin:auto;display:flex;flex-direction:column;align-items:center;gap:clamp(12px,2.4vh,22px);width:100%;max-width:640px';
+ var title = document.createElement('div');
+ title.style.cssText = 'font:900 clamp(20px,5.5vw,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:auto;height:auto;max-width:min(72vw,360px);max-height:52vh;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:clamp(11px,1.8vh,14px) clamp(26px,8vw,40px);font:800 clamp(15px,4.4vw,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);flex:0 0 auto';
+ btn.addEventListener('click', function () {
+ ov.style.transition = 'opacity .25s'; ov.style.opacity = '0';
+ setTimeout(finish, 220);
+ });
+ inner.appendChild(title);
+ inner.appendChild(row);
+ inner.appendChild(btn);
+ ov.appendChild(inner);
+ 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;
+ /* MG3 server-authoritative: เปิดเมื่อได้รับ stack-state ที่ srv:true → client เลิก host-sim
+ (ลำดับตา/แกว่ง/ดรอปบอท/จบเกม = server เป็นเจ้าของ; ผู้เล่นส่งเจตนา stack-drop-req) */
+ let stackServerAuthPlay = false;
+ /* server-auth swing: extrapolate phase จาก sample ล่าสุดของ server (กัน drift → บล็อกวาปตอนปล่อย) */
+ let stackSrvPhaseAnchor = 0;
+ let stackSrvPhaseAnchorAt = 0;
+ /* freeze เฟสที่เห็น ณ ตอนกดวาง ไว้จน echo ดรอปกลับมา → บล็อกไม่แกว่งต่อแล้วเด้งกลับช่วงรอ RTT */
+ let stackLocalSwingFrozen = false;
+ let stackLocalSwingFrozenAt = 0;
+
+ /** ลำดับตา MG3 = ผู้เล่นจริงทุกคน + บอท เรียงตาม id แบบคงที่ → ทุกเครื่องได้ลำดับ/ที่นั่งเดียวกัน (shared tower)
+ (เดิม [human=ตัวเอง seat1 + bots] ต่อเครื่อง → แต่ละเครื่องมองตัวเองเป็นคนเดียว เลยแยกตึกกัน) */
+ function rebuildStackPreviewTurnOrder() {
+ if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
+ stackPreviewTurnOrder = null;
+ return;
+ }
+ if (stackServerAuthPlay) return; /* server-auth: ลำดับตามาจาก stack-state เท่านั้น */
+ if (!detectiveCaseFillBots) {
+ /* editor preview (คนเดียว): คงพฤติกรรมเดิม [human ตัวเอง seat1 + บอท] */
+ const bots = [...others.keys()].filter(isPreviewBotId).sort();
+ stackPreviewTurnOrder = [{ kind: 'human', seat: 1, id: myId != null ? String(myId) : '__me__' }];
+ for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
+ stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, id: bots[i] || null, botId: bots[i] || null });
+ }
+ stackPreviewTurnIndex = 0;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ return;
+ }
+ /* เล่นจริง: non-host ไม่สร้าง order เอง — ใช้ของ host จาก stack-state (กัน order ไม่ตรง/client ตกหล่นช่วงโหลด) */
+ if (!isMePlayHost()) return;
+ /* host: ผู้เล่นจริงทุกคน (ตัด banned spectator กัน stall) + บอท เรียง id → ที่นั่งคงที่; preserve คนที่กำลังถึงตา */
+ const prevActorId = (stackCurrentTurnEntryPlay() && stackCurrentTurnEntryPlay().id) || null;
+ const parts = [];
+ /* dedup คนซ้ำ (ghost reconnect) ก่อนสร้างคิวตา — กัน seat ผี (ตาที่ไม่มีใครวาง = เกมค้าง)
+ ⚠️ เทียบด้วย playerKey เท่านั้น — **ห้าม dedup ด้วยชื่อ**: 2 คนตั้งชื่อเหมือนกัน (เช่น "MONE") = คนละ key = คนละคน
+ ต้องได้ seat ทั้งคู่ (เดิม fallback ชื่อ → client ที่ชื่อซ้ำ host ถูกตัด = "ไม่ได้เล่นเลย") */
+ const seenHumanKey = new Set();
+ const tryAddHuman = (id, ent) => {
+ const pk = (ent && ent.playerKey) ? String(ent.playerKey) : (String(id) === String(myId) ? (typeof getJdPlayerKey === 'function' ? getJdPlayerKey() : '') : '');
+ if (pk) {
+ if (seenHumanKey.has(pk)) return; /* playerKey เดียวกัน = ghost reconnect → ข้าม */
+ seenHumanKey.add(pk);
+ }
+ parts.push({ kind: 'human', id: String(id) });
+ };
+ if (myId != null && !playBannedSpectator) tryAddHuman(myId, me);
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) { parts.push({ kind: 'bot', id: String(id), botId: String(id) }); return; }
+ if (o && o.bannedSpectator) return; /* คนถูกแบน = ดูเฉยๆ ไม่เข้าคิววาง */
+ tryAddHuman(id, o);
+ });
+ parts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ const capped = parts.slice(0, STACK_PREVIEW_TURN_COUNT);
+ stackPreviewTurnOrder = capped.map((p, i) => ({
+ kind: p.kind, seat: i + 1, id: p.id, botId: p.kind === 'bot' ? p.botId : null,
+ }));
+ let ni = -1;
+ if (prevActorId != null) ni = stackPreviewTurnOrder.findIndex((e) => String(e.id) === String(prevActorId));
+ if (ni >= 0) stackPreviewTurnIndex = ni;
+ else if (stackPreviewTurnIndex >= stackPreviewTurnOrder.length) stackPreviewTurnIndex = 0;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ broadcastStackStatePlay();
+ }
+
+ /** host broadcast ลำดับตา + index ปัจจุบัน → non-host ใช้ตรงกัน (single source of truth) */
+ function broadcastStackStatePlay() {
+ if (stackServerAuthPlay) return; /* server-auth: server เป็นเจ้าของ stack-state */
+ if (!detectiveCaseFillBots || !isMePlayHost() || !socket || myId == null || !stackTurnOrderReadyPlay()) return;
+ try {
+ socket.emit('stack-state', {
+ idx: stackPreviewTurnIndex,
+ /* theme ต่อที่นั่ง (host เป็นเจ้าของ) → ทุกจอลงสี block ตรงกัน (เดิม non-host คำนวณสีเองจาก playTint → ไม่ตรง 2 จอ) */
+ order: stackPreviewTurnOrder.map((e) => ({ kind: e.kind, seat: e.seat, id: e.id, botId: e.botId || null, theme: stackSeatThemeForBroadcastPlay(e.seat) })),
+ /* สีของ block ที่ "วางแล้ว" ทั้งหอ (host authoritative) → client sync เขียนทับ layer ที่เพี้ยนค้าง (จากจังหวะ dual-host ต้นเกม) */
+ lthemes: (stackMini && Array.isArray(stackMini.layers)) ? stackMini.layers.map(function (l) { return (l && l.theme >= 1 && l.theme <= 8) ? l.theme : 0; }) : [],
+ });
+ logStackSeatColors('host-bcast');
+ } catch (e) { /* ignore */ }
+ }
+
+ /** order พร้อมใช้ (มีอย่างน้อย 1 ที่นั่ง) */
+ function stackTurnOrderReadyPlay() {
+ return !!(stackPreviewTurnOrder && stackPreviewTurnOrder.length >= 1);
+ }
+ /** (host) คิวตาตรงกับผู้เล่น/บอทปัจจุบันไหม — ใช้ auto-heal late-join (client เข้าทีหลังต้องได้ seat ไม่งั้น "เล่นไม่ได้" + ไม่มีสี)
+ เทียบรายชื่อคน (me + others ที่ไม่ใช่บอท/ไม่ถูกแบน) + บอท กับ entry ในคิว; ต่างเมื่อไหร่ = ต้อง rebuild */
+ let _stackHumansSig = '';
+ function stackOrderNeedsRebuildPlay() {
+ if (!stackTurnOrderReadyPlay() || !Array.isArray(stackPreviewTurnOrder)) return true;
+ /* เช็คเฉพาะ "คน" (humans ≤ maxPlayers จึงลงคิวครบเสมอ ไม่โดน cap ตัด) — บอทจัดการใน rebuild เอง */
+ const cur = [];
+ if (myId != null && !playBannedSpectator) cur.push(String(myId));
+ others.forEach((o, id) => {
+ if (isPreviewBotId(id)) return;
+ if (o && o.bannedSpectator) return;
+ cur.push(String(id));
+ });
+ cur.sort();
+ const sig = cur.join('|');
+ if (sig === _stackHumansSig) return false; /* คนไม่เปลี่ยน → ไม่ต้องเช็ค (กัน rebuild ถี่) */
+ const ord = [];
+ stackPreviewTurnOrder.forEach((e) => { if (e && e.kind !== 'bot' && !e.botId && e.id != null) ord.push(String(e.id)); });
+ ord.sort();
+ const need = (sig !== ord.join('|'));
+ if (!need) _stackHumansSig = sig; /* ตรงแล้ว → cache กันเช็คซ้ำทุกเฟรม */
+ return need;
+ }
+ function stackCurrentTurnEntryPlay() {
+ if (!stackTurnOrderReadyPlay()) return null;
+ return stackPreviewTurnOrder[stackPreviewTurnIndex % stackPreviewTurnOrder.length] || null;
+ }
+ function isMyStackTurn() {
+ if (!playBotsEnabled()) return true; /* เล่นเดี่ยว → กดได้ */
+ if (detectiveCaseFillBots && !stackTurnOrderReadyPlay()) return false; /* เล่นจริง: รอ order จาก host ก่อน */
+ if (!stackTurnOrderReadyPlay()) return true;
+ const cur = stackCurrentTurnEntryPlay();
+ if (!cur || cur.kind !== 'human') return false;
+ if (!detectiveCaseFillBots) return true; /* editor preview: human = previewer คนเดียว */
+ return String(cur.id) === String(myId);
+ }
+ function isStackCurrentBotTurnPlay() {
+ const cur = stackCurrentTurnEntryPlay();
+ return !!(cur && cur.kind === 'bot');
+ }
+ /** เจ้าของตาที่วาง broadcast ผลการวาง → ทุกเครื่อง apply เหมือนกัน + เลื่อนตาพร้อมกัน */
+ function broadcastStackDropPlay(hit, seat, heavy, actorId, isBot) {
+ if (!detectiveCaseFillBots || !socket || myId == null || !hit) return; /* broadcast เฉพาะเล่นจริง */
+ try {
+ socket.emit('stack-drop', {
+ placedCx: hit.placedCx, placedW: hit.placedW, miss: !!hit.miss, perfect: !!hit.perfect,
+ pts: hit.pts || 0, progressDelta: hit.progressDelta || 0, landOffsetTiles: hit.landOffsetTiles || 0,
+ overlap: hit.overlap, supportRatio: hit.supportRatio, delta: hit.delta || 0,
+ seat: seat || 1, heavy: !!heavy, actorId: actorId != null ? String(actorId) : '', isBot: !!isBot,
+ /* สี (theme) ที่ host/เจ้าของตา resolve ตอนวาง → ส่งไปด้วย ให้บล็อกที่วางบนทุกจอใช้สีเดียวกัน (กัน host/client คนละสี) — never-null */
+ theme: stackSeatThemeForBroadcastPlay(seat),
+ });
+ } catch (e) { /* ignore */ }
+ }
+
+ function advanceStackPreviewTurn() {
+ if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return;
+ if (stackServerAuthPlay) return; /* server-auth: idx มาจาก server (stack-state) เท่านั้น — กัน double-advance */
+ /* เล่นจริง: index คุมโดย host เท่านั้น (non-host รับจาก stack-state) กันลำดับตาเหลื่อมกัน */
+ if (detectiveCaseFillBots && !isMePlayHost()) return;
+ stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackPreviewTurnOrder.length;
+ stackPreviewBotThinkUntil = 0;
+ markStackNextDropVisualDirty();
+ broadcastStackStatePlay();
+ }
+
+ function isStackPreviewHumanTurn() {
+ if (!playBotsEnabled() || !stackTurnOrderReadyPlay()) return true;
+ const cur = stackCurrentTurnEntryPlay();
+ return !!(cur && cur.kind === 'human');
+ }
+
+ function getStackPreviewCurrentSeat() {
+ if (!stackTurnOrderReadyPlay()) return 1;
+ const cur = stackCurrentTurnEntryPlay();
+ return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
+ }
+
+ function getStackDropActorSeat() {
+ if (playBotsEnabled() && stackTurnOrderReadyPlay()) {
+ return getStackPreviewCurrentSeat();
+ }
+ return 1;
+ }
+
+ function markStackNextDropVisualDirty() {
+ if (stackMini) stackMini.nextDropVisualDirty = true;
+ }
+
+ /* #4 MG3 block สีตาม user — Block-Code-1..8 ตรง lobby theme (PLAY_LOBBY_THEME_HEX) + heavy '-2' */
+ const STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink'];
+ const STACK_BLOCK_THEME_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '.png'; });
+ const STACK_BLOCK_THEME_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return BASE + '/img/TowerBlock/Block-Code-' + s + '-2.png'; });
+ function stackThemeNormalUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_NORMAL[theme - 1] : ''; }
+ function stackThemeHeavyUrlPlay(theme) { return (theme >= 1 && theme <= 8) ? STACK_BLOCK_THEME_HEAVY[theme - 1] : ''; }
+ /** theme (1-8) ของผู้เล่นที่ครอบครอง seat นั้นในลำดับตา — ใช้ลงสี block ตามผู้วาง (ไม่แตะ seat/turn logic) */
+ function stackSeatColorThemePlay(seat) {
+ if (!isStack() || !stackTurnOrderReadyPlay()) return null;
+ const s = Math.floor(Number(seat)) || 0;
+ const entry = stackPreviewTurnOrder.find(function (e) { return e && e.seat === s; });
+ if (!entry) return null;
+ /* (1) สีที่ host broadcast มาทาง stack-state = SINGLE SOURCE OF TRUTH → client ยึดค่า host เสมอ (สีตรง host ทุกจอ)
+ เกม MG3 host เป็นเจ้าของลำดับตา/สี; host เองไม่มี entry.theme (คำนวณสดตอน broadcast) → ตกไป (2) authoritative
+ เหตุที่ต้อง broadcast-first: ถ้า client คำนวณเอง (lobbyThemeIndexSrv/playLobbyBotThemes ของ client) แล้วข้อมูลมาช้า/ไม่ตรง
+ → สีหลุดจาก host เพี้ยนเรื่อย ๆ. ใช้ค่า host = ไม่มีทางหลุด */
+ const bcTheme = parsePlayLobbyThemeIndex(entry.theme);
+ if (bcTheme) return bcTheme;
+ /* (2) authoritative ต่อ entity (สำหรับ "host เอง" + ตอน client ยังไม่ได้รับ broadcast):
+ bot → playLobbyBotThemes[slot] (server) ; human → lobbyThemeIndexSrv (= server lobbyColorThemeIndex) */
+ const botId = entry.botId || (entry.kind === 'bot' ? entry.id : null);
+ if (botId && isPreviewBotId(botId)) {
+ const slot = parseInt(String(botId).slice(PREVIEW_BOT_PREFIX.length), 10);
+ const bt = (slot >= 0 && Array.isArray(playLobbyBotThemes)) ? playLobbyBotThemes[slot] : null;
+ const botIdx = bt ? parsePlayLobbyThemeIndex(bt.themeIndex) : null;
+ if (botIdx) return botIdx;
+ } else if (entry.id != null) {
+ const entH = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id)));
+ const humanIdx = entH ? parsePlayLobbyThemeIndex(entH.lobbyThemeIndexSrv) : null;
+ if (humanIdx) return humanIdx;
+ }
+ /* (3) ทางเลือกสุดท้าย: เดาจาก playTint (nearest hex — อาจไม่ตรงข้ามจอ) */
+ let ent = null;
+ if (entry.id != null) ent = (myId != null && String(entry.id) === String(myId)) ? me : (others.get(entry.id) || others.get(String(entry.id)));
+ if (!ent && entry.botId) ent = others.get(entry.botId) || others.get(String(entry.botId));
+ return ent ? playLobbyThemeIndexForEntityPlay(ent) : null;
+ }
+
+ /** สีที่ host จะ broadcast — "ไม่มีทาง null": resolve ปกติ ไม่ได้ค่อย default ตาม seat
+ → client (broadcast-first) ยึดค่า host ได้เสมอ ไม่ตกไปคำนวณเอง = สีตรง host ทุกจอแน่นอน */
+ function stackSeatThemeForBroadcastPlay(seat) {
+ const t = stackSeatColorThemePlay(seat);
+ if (t >= 1 && t <= 8) return t;
+ const s = Math.floor(Number(seat)) || 1;
+ return ((s - 1) % 8) + 1;
+ }
+
+ /** ส่ง log ไป /gamelog (ดูรวมที่หน้าเว็บ) */
+ function gamelogPost(cat, ev, detail, who) {
+ try {
+ fetch('/gamelog/log.php', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true,
+ body: JSON.stringify({ cat: cat || 'อื่นๆ', room: (typeof spaceId === 'string' ? spaceId : ''), who: who || '', ev: ev || '', detail: detail || '', src: 'client' }),
+ }).catch(function () {});
+ } catch (e) { /* ignore */ }
+ }
+ /** วินิจฉัยสี block MG3 ต่อ seat — เทียบ "สี avatar (playTint)" vs "สี block (resolved)"; ถ้าไม่ตรง → ส่งเข้า /gamelog
+ เรียกเอง: window.__mg3ColorLog() หรือ auto throttle */
+ let _mg3ColorLogAt = 0;
+ let _mg3SnapAt = 0;
+ let _mg3LastMismatchKey = '';
+ function logStackSeatColors(tag, force) {
+ try {
+ if (!isStack() || !stackTurnOrderReadyPlay()) return;
+ const now = Date.now();
+ if (!force && now - _mg3ColorLogAt < 2000) return;
+ _mg3ColorLogAt = now;
+ const rows = stackPreviewTurnOrder.map(function (e) {
+ let ent = null;
+ if (e.id != null) ent = (myId != null && String(e.id) === String(myId)) ? me : (others.get(e.id) || others.get(String(e.id)));
+ if (!ent && e.botId) ent = others.get(e.botId) || others.get(String(e.botId));
+ const tint = ent && ent.playTint && ent.playTint.body ? ent.playTint.body : '';
+ const avatarIdx = tint ? nearestIndexFromHexPlay(tint, PLAY_LOBBY_THEME_HEX) : null; /* สีจริงของ avatar (จาก playTint) */
+ const blockIdx = stackSeatColorThemePlay(e.seat); /* สี block ที่จะวาด */
+ return { seat: e.seat, kind: e.kind, nick: (ent && ent.nickname) || String(e.id || e.botId || '').slice(0, 6), avatarIdx: avatarIdx, blockIdx: blockIdx, srv: (ent && ent.lobbyThemeIndexSrv) || null, bc: e.theme || null, ok: (avatarIdx && blockIdx) ? (avatarIdx === blockIdx) : null };
+ });
+ console.log('[mg3-color/' + (tag || '') + '] host=' + isMePlayHost(), JSON.stringify(rows));
+ const role = isMePlayHost() ? 'host' : 'client';
+ /* (A) snapshot ทุก ~5 วิ จาก "ทั้ง host และ client" → เทียบใน /gamelog ได้: block(=สีที่วาด) av(=สี avatar) srv bc */
+ if (now - _mg3SnapAt > 5000) {
+ _mg3SnapAt = now;
+ /* รายชื่อ "คน" ใน others (เทียบกับคิว) — หา root "client หายจากคิว" */
+ const othH = [];
+ if (myId != null) othH.push((me && me.nickname ? me.nickname : 'me') + '(self' + (playBannedSpectator ? ',BAN' : '') + ')');
+ others.forEach(function (o, id) {
+ if (isPreviewBotId(id)) return;
+ othH.push((o && o.nickname ? o.nickname : String(id).slice(0, 5)) + (o && o.bannedSpectator ? '(BAN)' : ''));
+ });
+ const ordSeats = rows.length;
+ /* block ที่ "วางแล้ว" (frozen layer.theme) — ตัวที่ user เห็นเพี้ยน; เทียบ host vs client ทีละ layer (seat:theme) */
+ let lyr = '';
+ if (stackMini && Array.isArray(stackMini.layers)) {
+ const L = stackMini.layers;
+ lyr = ' || layers(' + L.length + ')=[' + L.slice(-12).map(function (l) { return (l && l.seat != null ? l.seat : '?') + ':' + (l && l.theme != null ? l.theme : 'null'); }).join(',') + ']';
+ }
+ const summary = 'order=' + ordSeats + 'seat othHumans=[' + othH.join(',') + '] :: '
+ + rows.map(function (r) { return 's' + r.seat + ':' + (r.nick || '?').slice(0, 5) + ' blk=' + r.blockIdx + ' av=' + r.avatarIdx; }).join(' | ') + lyr;
+ gamelogPost('MG3', 'color-snap', summary, role);
+ }
+ /* (B) mismatch (avatar≠block บนจอเดียวกัน) → ส่งทันที (เปลี่ยนค่อยส่งซ้ำ) */
+ const bad = rows.filter(function (r) { return r.ok === false; });
+ if (bad.length) {
+ const key = role + '|' + bad.map(function (r) { return r.seat + ':' + r.avatarIdx + '!=' + r.blockIdx; }).join(',');
+ if (key !== _mg3LastMismatchKey) {
+ _mg3LastMismatchKey = key;
+ const detail = bad.map(function (r) { return r.nick + ' seat' + r.seat + ' avatar#' + r.avatarIdx + ' แต่ block#' + r.blockIdx + ' (srv' + r.srv + '/bc' + r.bc + ')'; }).join(' | ');
+ gamelogPost('MG3', 'color-mismatch', detail, role);
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+ window.__mg3ColorLog = function () { logStackSeatColors('manual', true); };
+
+ function pickStackBlockHeavyVisual(seat) {
+ const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1));
+ let hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
+ /* ถ้าใช้สี theme (Block-Code) — heavy variant '-2' มีครบ ใช้แทน admin slot */
+ const _theme = stackSeatColorThemePlay(seat);
+ if (_theme && stackThemeHeavyUrlPlay(_theme)) hU = stackThemeHeavyUrlPlay(_theme);
+ /** 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.pendingDropTheme = null; /* ดรอป local/host → ใช้ stackSeatColorThemePlay เอง (remote ตั้งทับทีหลัง) */
+ stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy);
+ stackMini.nextDropVisualDirty = false;
+ }
+
+ function normalizePlayStackSixUrlsFromTiming(arr) {
+ const out = ['', '', '', '', '', '', '', '']; /* 8 slot ตาม lobby theme (#4) */
+ if (!Array.isArray(arr)) return out;
+ for (let i = 0; i < 8; i++) {
+ out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
+ if (out[i]) ensureGauntletAssetImage(out[i]);
+ }
+ return out;
+ }
+
+ function resolveStackBlockSpriteRec(seat, heavy, themeOverride) {
+ const tryHeavy = !!heavy;
+ /* #4 สี block = theme ของผู้เล่นที่ seat นั้น (Block-Code-1..8) ถ้า resolve ได้ (มี turn order)
+ — fallback ไป admin slot URLs ถ้าไม่มี theme (เกม stack อื่น/ยังไม่มีลำดับตา)
+ themeOverride = สีที่ "freeze" ไว้บน layer ตอนวาง → กัน re-resolve เปลี่ยนสีเมื่อ turn order เปลี่ยน (สี block ที่วางแล้วคงที่+ตรงทุกจอ) */
+ const theme = (themeOverride >= 1 && themeOverride <= 8) ? themeOverride : stackSeatColorThemePlay(seat);
+ if (theme) {
+ const tn = stackThemeNormalUrlPlay(theme);
+ const th = stackThemeHeavyUrlPlay(theme);
+ const torder = tryHeavy ? [th, tn] : [tn, th];
+ for (let ti = 0; ti < torder.length; ti++) {
+ if (torder[ti]) return ensureGauntletAssetImage(torder[ti]);
+ }
+ }
+ const slot = Math.max(0, Math.min(7, (Math.floor(Number(seat)) || 1) - 1));
+ 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, o.themeOverride);
+ 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');
+
+ /** popup แจ้งเตือน/ผิดพลาด ดีไซน์เอง (แทน alert() ของเบราว์เซอร์ที่หน้าตาไม่เข้าธีม)
+ opts: { type:'error'|'info', okText, onClose } — onClose เรียกเมื่อกดตกลง/ปิด */
+ function showPlayAlert(message, opts) {
+ opts = opts || {};
+ const prev = document.getElementById('play-alert-modal');
+ if (prev) prev.remove();
+ const overlay = document.createElement('div');
+ overlay.id = 'play-alert-modal';
+ overlay.className = 'play-alert-modal' + (opts.type === 'error' ? ' play-alert-modal--error' : '');
+ const card = document.createElement('div');
+ card.className = 'play-alert-card';
+ card.setAttribute('role', 'alertdialog');
+ card.setAttribute('aria-modal', 'true');
+ const backdrop = document.createElement('div');
+ backdrop.className = 'play-alert-backdrop';
+ const icon = document.createElement('div');
+ icon.className = 'play-alert-icon';
+ icon.textContent = opts.type === 'error' ? '!' : 'i';
+ const msg = document.createElement('div');
+ msg.className = 'play-alert-msg';
+ msg.textContent = (message == null ? '' : String(message));
+ const ok = document.createElement('button');
+ ok.type = 'button';
+ ok.className = 'play-alert-ok';
+ ok.textContent = opts.okText || 'ตกลง';
+ card.appendChild(icon);
+ card.appendChild(msg);
+ card.appendChild(ok);
+ overlay.appendChild(backdrop);
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+ let closed = false;
+ const done = function () {
+ if (closed) return;
+ closed = true;
+ overlay.remove();
+ document.removeEventListener('keydown', onKey, true);
+ if (typeof opts.onClose === 'function') { try { opts.onClose(); } catch (e) { /* ignore */ } }
+ };
+ const onKey = function (e) {
+ if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); done(); }
+ };
+ ok.addEventListener('click', done);
+ if (!opts.requireButton) backdrop.addEventListener('click', done);
+ document.addEventListener('keydown', onKey, true);
+ setTimeout(function () { try { ok.focus(); } catch (e) { /* ignore */ } }, 30);
+ return { close: done };
+ }
+ window.__jdShowPlayAlert = showPlayAlert;
+ let mapData = null, tileSize = 32, myId = null;
+ let stackMini = null;
+ let stackFall = null;
+ let stackLastSwingEmit = 0; /* throttle broadcast เฟสแกว่ง MG3 (เจ้าของตา → คนอื่น mirror) */
+ let stackLastStateEmit = 0; /* throttle broadcast ลำดับตา+index MG3 (host → non-host adopt) */
+ let stackRemoteDropQueue = []; /* คิวดรอปจาก host/คนอื่น (MG3) — ทยอย apply ทีละอันกันทิ้ง */
+ /** เศษบล็อกที่พลาด/หล่น → ตกแบบ 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;
+ /** ความเร็วเดินเข้าเส้นชัยตอนจบ (ช่อง/วินาที) — ค่อยๆ เดินแทนกระพริบ snap */
+ const GAUNTLET_FINISH_WALK_TILES_PER_SEC = 7;
+ /** เพดานเวลาเดินเข้าเส้นชัย (กันค้างถ้าใครติด) — ต้อง < server grace (GAUNTLET_FINISH_WALK_MS) */
+ const GAUNTLET_FINISH_WALK_MAX_MS = 2600;
+ 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;
+ /* G1: scroll ผูกเวลา server (ทุกเครื่องตรงกัน ไม่ขึ้น resolution/frame-rate/host) — scroll = base + speed × (serverNow − anchor) */
+ let gauntletCrownRunwayLiveAnchorServerMs = 0; /* เวลาเริ่ม live (server epoch) */
+ let gauntletCrownRunwayServerLiveStartMs = 0; /* liveStartMs จาก server (gauntlet-sync) — anchor เดียวกันทุกเครื่อง */
+ let gauntletCrownRunwayBaseScrollPx = 0; /* ค่า scroll ตอน snap (resolution-independent เท่ากันทุกเครื่อง) */
+ let gauntletCrownRunwayBgFinishLatched = false;
+ /** ส่งสัญญาณ "ถึงเส้นชัย" ให้ server แล้วหรือยัง (กันส่งซ้ำ) — server จะหยุด obstacle/collision */
+ let gauntletFinishPhaseSignaled = false;
+ /** กำลังเดินค่อยๆ เข้าเส้นชัย (ก่อน freeze) — true ระหว่างทุกคนทยอยเดินมาที่เส้น */
+ let gauntletCrownRunwayWalkActive = false;
+ /** เวลาเริ่มเดินเข้าเส้นชัย (ms) — ใช้เพดานเวลา GAUNTLET_FINISH_WALK_MAX_MS */
+ let gauntletCrownRunwayWalkStartMs = 0;
+ /** >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 gauntletCrownRunwayWalkActive || 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);
+ /* แนบเส้นชัยกับ "ตัวนำหน้าสุดที่ยังไม่ตาย" (tx มากสุด) → ตัวนำหยุดที่เส้นพอดี ไม่เลยเส้น
+ (ตอนหยุด เราจะ snap ตัวที่เหลือมายืนที่เส้นเดียวกันด้วย ดู gauntletSnapLivingToFinishPlay) */
+ 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;
+ }
+
+ /** ตอนถึงเส้นชัย: ดึงตัวที่ยังไม่ตายทุกตัวมายืนที่ "เส้นชัย" — ไม่มีใครเลยเส้น, คนตายไม่ย้าย
+ เป้าหมาย = ตำแหน่งเส้นชัยที่ latch ไว้ (คงที่) ถ้ายังไม่ latch ใช้ tx ตัวนำสุด
+ เรียกทุกเฟรมระหว่าง freeze/latched เพื่อกันบอทที่ sim ยังขยับ "ไหล" หลุดจากเส้น */
+ function gauntletSnapLivingToFinishPlay() {
+ if (!mapData || !isGauntletCrownHeistMapPlay()) return;
+ const ts = mapData.tileSize || 32;
+ const { cw } = getCharacterFootprintWH(mapData);
+ let targetTx = null;
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) {
+ targetTx = gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5;
+ } else {
+ let leadTx = -Infinity;
+ const consider = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x);
+ if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx);
+ };
+ consider(me);
+ others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); });
+ if (Number.isFinite(leadTx) && leadTx !== -Infinity) targetTx = leadTx;
+ }
+ if (targetTx == null || !Number.isFinite(targetTx)) return;
+ const snap = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ if (Number.isFinite(ent.x)) ent.x = targetTx;
+ if (ent.tx != null) ent.tx = targetTx;
+ };
+ snap(me);
+ others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; snap(o); });
+ }
+
+ /** tx เป้าหมายที่เส้นชัย (คอลัมน์ที่ทุกคนต้องมายืน) — null ถ้าคำนวณไม่ได้ */
+ function gauntletFinishTargetTxPlay() {
+ if (!mapData || !isGauntletCrownHeistMapPlay()) return null;
+ const ts = mapData.tileSize || 32;
+ const { cw } = getCharacterFootprintWH(mapData);
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX != null) {
+ return gauntletCrownRunwayFinishAlignLatchedWorldX / ts - cw * 0.5;
+ }
+ let leadTx = -Infinity;
+ const consider = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ const tx = Number.isFinite(ent.tx) ? ent.tx : Number(ent.x);
+ if (Number.isFinite(tx)) leadTx = Math.max(leadTx, tx);
+ };
+ consider(me);
+ others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; consider(o); });
+ return (Number.isFinite(leadTx) && leadTx !== -Infinity) ? leadTx : null;
+ }
+
+ /** เดินทุกคนที่ยังไม่ตาย "ค่อยๆ" เข้าหาเส้นชัยทีละเฟรม (แทน snap กระพริบ)
+ คืน true เมื่อทุกคนถึงเส้นแล้ว (เริ่ม freeze→จบเกมต่อ) */
+ function gauntletWalkLivingToFinishStepPlay(dtSec) {
+ const targetTx = gauntletFinishTargetTxPlay();
+ if (targetTx == null || !Number.isFinite(targetTx)) return true;
+ const step = Math.max(0.04, GAUNTLET_FINISH_WALK_TILES_PER_SEC * Math.max(0, Number(dtSec) || 0));
+ const eps = 0.06;
+ let allArrived = true;
+ const walk = (ent) => {
+ if (!ent || ent.gauntletEliminated) return;
+ let x = Number.isFinite(ent.x) ? ent.x : (Number.isFinite(ent.tx) ? ent.tx : targetTx);
+ if (x < targetTx - eps) {
+ x = Math.min(targetTx, x + step);
+ ent.direction = 'right';
+ ent.isWalking = true;
+ allArrived = false;
+ } else {
+ x = targetTx; /* ไม่ให้ใครเลยเส้นชัย (เลยมา → ดึงกลับมาที่เส้น) */
+ ent.isWalking = false;
+ }
+ ent.x = x;
+ ent.tx = x;
+ };
+ walk(me);
+ others.forEach((o, id) => { if (!playBotsEnabled() && isPreviewBotId(id)) return; walk(o); });
+ return allArrived;
+ }
+
+ /** เริ่มเฟส "เดินเข้าเส้นชัย": ล็อก align → snap scroll → แจ้ง server หยุด obstacle ทันที (กันตายระหว่างเดิน)
+ จากนั้น tick จะค่อยๆ เดินทุกคนมาที่เส้น แล้วค่อยเข้า freeze→จบเกม */
+ function gauntletCrownRunwayBeginWalkToFinishPlay() {
+ if (gauntletCrownRunwayWalkActive || gauntletCrownRunwayBgStripFreezeSinceMs > 0 || gauntletCrownRunwayBgFinishLatched) return;
+ if (gauntletCrownRunwayFinishAlignLatchedWorldX == null) {
+ const leadAlign = gauntletCrownRunwayLeadingEntityAlignWorldXPlay();
+ if (leadAlign != null) gauntletCrownRunwayFinishAlignLatchedWorldX = leadAlign;
+ }
+ gauntletCrownRunwaySnapScrollToFinishStopPlay();
+ gauntletCrownRunwayWalkActive = true;
+ gauntletCrownRunwayWalkStartMs = Date.now();
+ /* G3: เข้าเฟสเดินเข้าเส้นชัย → เคลียร์ท่ากระโดด (กันค้างลอยกลางอากาศ — ต้องเดินเข้าเส้น ไม่ใช่กระโดดค้าง) */
+ meGauntletJumpTicks = 0; meGauntletJumpVis = 0;
+ others.forEach((o) => { if (o) { o.gauntletJumpTicks = 0; o.gauntletJumpVis = 0; } });
+ /* แจ้ง server ครั้งเดียว: เข้าโซนเส้นชัย → หยุด obstacle + collision (กันโดนชน/-10 ตอนกำลังเดินเข้าเส้น)
+ server จะรอ grace (GAUNTLET_FINISH_WALK_MS) ให้เดินครบก่อนค่อยจบ */
+ if (!gauntletFinishPhaseSignaled) {
+ gauntletFinishPhaseSignaled = true;
+ try { socket.emit('gauntlet-finish-phase'); } catch (_e) { /* ignore */ }
+ }
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ }
+
+
+ /**
+ * คอลัมน์บนกริดที่เส้น 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();
+ /* แนวนอน: คลิปเต็มความกว้างที่มองเห็น (worldMinX..worldMaxX) แทนตัดที่ความกว้างแมพ (mapWpx)
+ → จอกว้างทุก resolution รันเวย์โชว์ยาวเต็ม ไม่เหลือขอบดำซ้าย-ขวา · แนวตั้งคงคลิป=ความสูงแมพ (ไม่กระทบเส้นชัย) */
+ ctx.rect(worldMinX, 0, cwR, 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;
+ const alignWorldX = gauntletCrownRunwayFinishAlignMapWorldXPlay();
+ if (alignWorldX != null) {
+ /* world-based: หยุดเมื่อ "เส้นชัย (world X)" ถึงตัวนำ — ไม่ขึ้นกับความละเอียดจอ
+ (เดิมมี gate g.scrollView >= minScrollForFinishPass ที่ผูก cwWorld = canvas.width/zoom
+ → จอกว้างต้อง scroll มากกว่าก่อนหยุด = ตัวละครวิ่งเลยเส้นชัยบนจอที่ resolution ต่างกัน) */
+ const finishMapX = gauntletCrownRunwayFinishLineMapWorldXPlay();
+ if (finishMapX == null) return false;
+ return finishMapX <= alignWorldX + 1.5;
+ }
+ /* fallback (ไม่มีตัวนำให้ align) — ใช้ gate scroll + สัดส่วนจอแบบเดิม */
+ if (g.scrollView < g.minScrollForFinishPass - 0.01) return false;
+ 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 กระพริบ)
+ tick จะเดินทุกคนมาที่เส้นจนครบแล้วค่อยเข้า freeze→จบเกม */
+ function gauntletCrownRunwayBeginFinishStopPlay() {
+ gauntletCrownRunwayBeginWalkToFinishPlay();
+ 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;
+ /* G1: เก็บ base (ค่า snap นี้ — เท่ากันทุกเครื่อง) + anchor เวลาเริ่ม (server epoch) → scroll คำนวณจากเวลา server */
+ gauntletCrownRunwayBaseScrollPx = gauntletCrownRunwayBgScrollPx;
+ /* anchor = liveStartMs จาก server (gauntlet-sync) — authoritative เดียวกันทุกเครื่อง + สดเสมอ
+ (เดิมใช้ playMissionLiveBeginsAt ที่ค้างจากเกมก่อน → scroll กระโดด/host-client ไม่ตรง)
+ fallback: liveBeginsAt ถ้าสด ไม่งั้น serverNow() */
+ if (gauntletCrownRunwayServerLiveStartMs > 0) {
+ gauntletCrownRunwayLiveAnchorServerMs = gauntletCrownRunwayServerLiveStartMs;
+ } else {
+ const _lba = (typeof playMissionLiveBeginsAt !== 'undefined') ? Number(playMissionLiveBeginsAt) : 0;
+ const _nowS = serverNow();
+ const _el = _nowS - _lba;
+ gauntletCrownRunwayLiveAnchorServerMs = (_lba > 0 && _el >= -2000 && _el < 10000) ? _lba : _nowS;
+ }
+ gauntletCrownRunwaySpawnScrollSnapped = true;
+ }
+
+ function reloadGauntletCrownRunwayBgFromMap() {
+ gauntletCrownRunwayBgImgs = [null, null, null, null, null];
+ gauntletCrownRunwayBgScrollPx = 0;
+ gauntletCrownRunwayLiveAnchorServerMs = 0; /* G1: รีเซ็ต anchor รอบใหม่ */
+ gauntletCrownRunwayServerLiveStartMs = 0;
+ gauntletCrownRunwayBaseScrollPx = 0;
+ gauntletCrownRunwayBgFinishLatched = false;
+ gauntletFinishPhaseSignaled = false;
+ gauntletCrownRunwayWalkActive = false;
+ gauntletCrownRunwayWalkStartMs = 0;
+ 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) { gauntletSnapLivingToFinishPlay(); return; }
+
+ /* เฟส "เดินเข้าเส้นชัย" — ค่อยๆ เดินทุกคนมาที่เส้นแทน snap กระพริบ แล้วค่อยเข้า freeze→จบ */
+ if (gauntletCrownRunwayWalkActive) {
+ const forceDone = (Date.now() - gauntletCrownRunwayWalkStartMs) >= GAUNTLET_FINISH_WALK_MAX_MS;
+ const arrived = gauntletWalkLivingToFinishStepPlay(dtSec);
+ if (arrived || forceDone) {
+ gauntletCrownRunwayWalkActive = false;
+ gauntletSnapLivingToFinishPlay(); /* จัดเป๊ะที่เส้นชัยครั้งเดียว */
+ gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
+ }
+ try { syncGauntletCrownJumpButton(); } catch (_sj) { /* ignore */ }
+ try { draw(); } catch (_d) { /* ignore */ }
+ return;
+ }
+
+ if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) {
+ gauntletCrownRunwayMaintainFinishScrollPlay();
+ gauntletSnapLivingToFinishPlay(); /* ตรึงทุกคนที่ยังไม่ตายไว้ที่เส้นชัยทุกเฟรม กันบอทไหลหลุด */
+ 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;
+ }
+ /* ตามกฎใหม่: เข้าเส้นชัย/รอด ≥1 คน (ไม่ถูกดันตกขอบ) = result-complete · ตายหมด = result-gameover (ไม่มี timeup) */
+ const mg2LocalMission = gauntletCrownHeistBuildLocalCrownMissionPlay();
+ const mg2LocalWin = missionSurvivorsPlay(mg2LocalMission) >= 1 || missionAnyScorePlay(mg2LocalMission);
+ beginSimpleResultFlashThenGcmPlay(mg2LocalMission, mg2LocalWin ? 'result-complete.png' : 'result-gameover.png');
+ } catch (_e) { /* ignore */ }
+ try {
+ draw();
+ } catch (_e2) { /* ignore */ }
+ }, 340);
+ }
+ }
+ return;
+ }
+
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) {
+ gauntletCrownRunwayBeginWalkToFinishPlay(); /* เริ่มเดินค่อยๆ เข้าเส้นชัย (แทน snap+freeze ทันที) */
+ try {
+ draw();
+ } catch (_d) { /* ignore */ }
+ return;
+ }
+ /* G1: scroll = base + speed × (serverNow − anchor) → ทุกเครื่องเลื่อนตรงกัน ไม่ขึ้น resolution/frame-rate/host
+ (เดิม scrollPx += speed×dt นับ local จากเวลาเริ่มของตัวเอง → เลื่อนไม่ตรงกัน + host หลุด/resize เพี้ยน)
+ fallback เป็นนับ local ถ้ายังไม่มี anchor (กันค้าง) */
+ const gcrSpeed = gauntletCrownRunwayBgSpeedPxPerSec || 48;
+ let gcrTarget;
+ if (gauntletCrownRunwayLiveAnchorServerMs > 0) {
+ gcrTarget = gauntletCrownRunwayBaseScrollPx + gcrSpeed * Math.max(0, (serverNow() - gauntletCrownRunwayLiveAnchorServerMs)) / 1000;
+ } else {
+ gcrTarget = gauntletCrownRunwayBgScrollPx + gcrSpeed * dtSec;
+ }
+ /* ถ้า "จะถึง/เลย" จุดหยุด FINISH → หยุดเป๊ะตรงนั้น แล้วเริ่มจบเกมทันที */
+ const gcrLiveStopPx = gauntletCrownRunwayFinishStopScrollPxPlay();
+ if (gauntletCrownRunwayBgMapEligiblePlay() && gcrLiveStopPx != null
+ && gcrLiveStopPx >= gauntletCrownRunwayBgScrollPx - 0.01
+ && gcrTarget >= gcrLiveStopPx) {
+ gauntletCrownRunwayBgScrollPx = gcrLiveStopPx;
+ gauntletCrownRunwayBeginFinishStopPlay();
+ return;
+ }
+ /* monotonic — เลื่อนหน้าเท่านั้น (กัน clock jitter ดึงถอย) */
+ if (gcrTarget > gauntletCrownRunwayBgScrollPx) gauntletCrownRunwayBgScrollPx = gcrTarget;
+ }
+
+ /** 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 วิ่งหลบ: เร่งจังหวะขาให้ "วิ่ง" — 0.40 (80ms/เฟรม) เร็วสุดที่ยังลื่น ไม่กระตุก
+ (sprite มีแค่ 4 เฟรม ถ้าเร็วกว่านี้ เช่น 0.27 เฟรมจะสลับถี่เกินจนดูสั่น/กระตุก) */
+ const frameMs = gauntletRunnerAnimActivePlay() ? CHARACTER_ANIM_FRAME_MS * 0.40 : 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;
+ /* ใช้ธีมจาก server (กันสีซ้ำอยู่แล้ว) — ถ้าไม่มี ตกไปธีมแบบ "กระจายตามดัชนี" (ไม่สุ่ม hash ที่ซ้ำได้)
+ กัน bot สีเดียวกัน: bot N → ธีม ((N % 8)+1), สกิน ((N % 3)+1) */
+ /* 1) สีที่ lobby บันทึกไว้ (ให้ตรงกับ lobby เป๊ะ) — ไม่เขียนทับ */
+ try {
+ const savedMap = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}');
+ const saved = savedMap['__lobby_bot_' + idx];
+ if (saved && (saved.colorTheme || saved.colorSkin)) {
+ const st = playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin);
+ if (st) return st;
+ }
+ } catch (e) { /* ignore */ }
+ /* 2) ไม่มีค่าจาก lobby → ใช้ธีม server / กระจายตามดัชนี แล้วบันทึกไว้ */
+ const slot = playLobbyBotThemes[idx] || { themeIndex: (idx % 8) + 1, skinToneIndex: (idx % 3) + 1 };
+ 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;
+ }
+ }
+
+ /** แปลง hex/rgb → {r,g,b} (กันค่าเพี้ยน) */
+ function hexOrRgbToRgbPlay(v) {
+ const s0 = String(v || '').trim();
+ const m = s0.match(/rgba?\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
+ if (m) return { r: +m[1], g: +m[2], b: +m[3] };
+ let s = s0.replace('#', '');
+ if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
+ if (!/^[0-9a-fA-F]{6}$/.test(s)) return null;
+ const n = parseInt(s, 16);
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
+ }
+ function nearestIndexFromHexPlay(value, palette) {
+ const asIdx = Math.floor(Number(value));
+ if (asIdx >= 1 && asIdx <= palette.length) return asIdx; /* เก็บเป็น index อยู่แล้ว */
+ const t = hexOrRgbToRgbPlay(value);
+ if (!t) return null;
+ let best = -1; let bestD = Infinity;
+ for (let i = 0; i < palette.length; i++) {
+ const c = hexOrRgbToRgbPlay(palette[i]);
+ if (!c) continue;
+ const d = (c.r - t.r) * (c.r - t.r) + (c.g - t.g) * (c.g - t.g) + (c.b - t.b) * (c.b - t.b);
+ if (d < bestD) { bestD = d; best = i; }
+ }
+ return best >= 0 ? best + 1 : null;
+ }
+ /** theme/skin index ที่ผู้เล่นเลือกไว้ (จาก localStorage) — ส่งให้ server เพื่อกันบอทใช้สีเดียวกับเรา */
+ function getMyChosenLobbyColorIndicesPlay() {
+ let body = '';
+ let skin = '';
+ try {
+ const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
+ if (raw) { const j = JSON.parse(raw); if (j) { body = j.body || j.themeRgb || ''; skin = j.skinRgb || j.skin || ''; } }
+ } catch (e) { /* ignore */ }
+ try {
+ if (!body) body = localStorage.getItem('lobbyThemeColor') || '';
+ if (!skin) skin = localStorage.getItem('lobbySkinTone') || '';
+ } catch (e2) { /* ignore */ }
+ return {
+ themeIndex: nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX),
+ skinIndex: nearestIndexFromHexPlay(skin, PLAY_LOBBY_SKIN_HEX),
+ };
+ }
+
+ 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 || '');
+ /* ตัวเราเอง: ยึด "สีที่เลือกไว้" (localStorage) ก่อนทุกอย่าง — กัน peerRow/theme-index จาก server มาทับ (สีกระพริบ) */
+ if (myId != null && sid === String(myId)) {
+ const meChosen = loadPlayTintFromLobbyPlayerStorage();
+ if (meChosen) return meChosen;
+ }
+ 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 sid = peerId != null ? String(peerId) : '';
+ /* server-assigned theme index (อาจถูก reassign ตอนเลือกสีชนกัน) — เก็บไว้ใช้ลงสี "block MG3" ให้ตรงกันทุกจอ
+ (เดิม self ใช้สีจาก localStorage แต่ others ใช้ index จาก server → block สีไม่ตรง 2 จอ) */
+ const tiNum = parsePlayLobbyThemeIndex(themeIndex);
+ if (myId != null && sid === String(myId)) {
+ if (tiNum) me.lobbyThemeIndexSrv = tiNum;
+ /* ตัวเราเอง: ใช้ "สีจาก server index" (authoritative) เป็นหลัก — ให้สีเรา "ตรงกันทุกจอ" + block MG3 ตรงกับ avatar
+ เดิม override ด้วย localStorage → จอเราเห็นสีที่เลือก แต่จออื่น+block MG3 ใช้ server index = ไม่ตรง
+ (เคส server reassign สีตอนเลือกชนกัน → server index คือสีจริง ต้องยึดอันนี้) */
+ const tintMe = (tiNum) ? playTintFromLobbyThemeIndices(themeIndex, skinIndex) : null;
+ if (tintMe) { me.playTint = tintMe; return; }
+ const meChosen = loadPlayTintFromLobbyPlayerStorage();
+ if (meChosen) me.playTint = meChosen;
+ return;
+ }
+ const oEarly = others.get(peerId) || others.get(sid);
+ if (oEarly && tiNum) oEarly.lobbyThemeIndexSrv = tiNum;
+ const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex);
+ if (!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 */ }
+ }
+
+ /** วินิจฉัยสีในเกม: รันใน console บนแต่ละเครื่อง → เทียบสีที่ render ของทุกตัว */
+ window.__colorDebug = function () {
+ var rows = [];
+ rows.push({ who: '*ME*', id: String(myId == null ? '' : myId).slice(0, 6), renderTint: JSON.stringify(me && me.playTint) });
+ others.forEach(function (o, id) {
+ rows.push({ who: isPreviewBotId(id) ? 'BOT' : 'peer', id: String(id).slice(0, 10), name: o.nickname || '', renderTint: JSON.stringify(o.playTint) });
+ });
+ try { console.table(rows); } catch (e) { console.log(JSON.stringify(rows, null, 2)); }
+ var lsTheme = '?'; var lsTint = '?';
+ try { lsTheme = localStorage.getItem('lobbyThemeColor'); lsTint = localStorage.getItem(LOBBY_PLAYER_TINT_KEY); } catch (e) { /* ignore */ }
+ console.log('[color-dbg/game] myId=' + myId + ' playLobbyBotThemes=' + JSON.stringify(playLobbyBotThemes) +
+ ' LS.lobbyThemeColor=' + lsTheme + ' LS.PLAYER_TINT=' + lsTint);
+ return rows;
+ };
+
+ 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) {
+ /* 'Chatest' = placeholder ที่ไม่มีไฟล์ sprite จริง → อย่าโหลด (กัน 404 รัว) ใช้ default avatar generated แทน */
+ const validId = characterId && characterId !== LEGACY_PLACEHOLDER_CHARACTER_ID;
+ const img = validId ? 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) {
+ /* placeholder 'Chatest' ไม่มี sprite/เลเยอร์จริง → ปฏิบัติเป็นว่าง (ใช้ default avatar) กัน 404 รัวทุกเส้นทางวาด */
+ if (characterId === LEGACY_PLACEHOLDER_CHARACTER_ID) characterId = '';
+ 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;
+ /* time-based movement: เดิม step=MOVE_SPEED ต่อเฟรม → จอ 120fps เดินเร็ว 2 เท่า (คน/บอทวิ่งเร็วช้าไม่เท่ากัน)
+ playDtMul = dt*60 (clamp ≤3 กัน tunneling) คูณเข้ากับ step ทุกที่ → ความเร็วเท่ากันทุก FPS */
+ let playDtMul = 1;
+ let playTickLastTs = 0;
+ /** Anti-stuck (MG1/MG4): ดันชน "เพื่อน" กี่เฟรมก่อนปลดล็อกเดินผ่าน + ช่วงเวลาที่ผ่านได้ต่อเนื่อง */
+ const ANTI_STUCK_PEER_TICKS = 13;
+ const ANTI_STUCK_WINDOW_MS = 650;
+ /** 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) * playDtMul;
+ }
+
+ 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;
+ /** โค้งกระโดด local ของ "เรา" — เล่นเต็มอาร์ค (ขึ้น-ลง) เสมอเมื่อกดกระโดด
+ (server เซ็ต jumpTicks=0 ทันทีตอนข้ามสำเร็จ = ตัดอาร์ค + สัญญาณลอยหายพร้อมตอนดัน +1 → ดูเป็น "วาปข้าม")
+ ระหว่างอาร์คนี้ vis คุมเอง + glide แนวนอนช้า → เห็นเป็นกระโดดข้ามจริง ไม่ใช่วาป */
+ let meGauntletJumpAnimStart = 0;
+ let meGauntletJumpAnimDur = 0;
+ function isMeGauntletJumpAnimActivePlay() {
+ return meGauntletJumpAnimStart > 0 && (Date.now() - meGauntletJumpAnimStart) < meGauntletJumpAnimDur;
+ }
+ /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
+ let gauntletRuntimeTickMs = 220;
+ let gauntletRuntimeJumpTicks = 6;
+ let gauntletRuntimeJumpHeightMult = 0.52; /* MG2 ความสูงกระโดด (×tile) — รับจาก server game-timing (Admin ปรับได้) */
+ /** 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 = {};
+ /** จำนวนผู้เล่น (มนุษย์) ที่ server คาดว่าจะเข้าเล่นรอบนี้ — host เริ่มได้ต่อเมื่อมาครบ + กด Ready ครบ */
+ let missionExpectedActiveCount = 0;
+ 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 = {};
+ /** MG1: กันสั่งจบซ้ำเมื่อตรวจพบ "ตายหมด" (รวมบอท ฝั่ง client) */
+ let mg1AllDeadEndScheduled = false;
+ /** MG1: ตรวจว่าทุกคน(เรา+บอท+ผู้เล่นอื่น)ถูกล็อกตอบผิดหมด → จบเกมทันที (เฉพาะโหมดสืบสวน) */
+ function checkMg1AllDeadAndEnd() {
+ if (mg1AllDeadEndScheduled || previewMode) return;
+ if (!isQuiz() || !isDetectiveMinigamePlay()) return;
+ const meLocked = !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse);
+ if (!meLocked) return;
+ let anyAlive = false;
+ others.forEach(function (o, id) {
+ if (!o || o.bannedSpectator) return;
+ if (!playBotsEnabled() && isPreviewBotId(id)) return;
+ if (!(o.quizCannotTrue && o.quizCannotFalse)) anyAlive = true;
+ });
+ if (anyAlive) return;
+ mg1AllDeadEndScheduled = true;
+ /* ทุกคนตอบผิดหมด → host ขอให้ server จบเกม → server ส่ง quiz-ended → โชว์ผล/รอกดรับการ์ด (เหมือนเกมอื่น)
+ เดิม: auto tryFinish = รับผล/การ์ดเองทันที ไม่รอกด */
+ setTimeout(function () {
+ try {
+ if (isMePlayHost() && socket && socket.connected) socket.emit('quiz-end-request');
+ /* non-host: ไม่ต้องทำอะไร รอ server ส่ง quiz-ended มาโชว์ผล */
+ } catch (e) { /* ignore */ }
+ }, 1600);
+ }
+ /** ผีตามตัวเมื่อตอบผิด — ใช้เฉพาะแมป 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;
+ }
+
+ /** สุ่มช่องในโซนผีที่ footprint ลงได้ (ใช้คุมบอทผีให้อยู่ในโซน) — คืน {x,y} หรือ null */
+ function randomGhostZoneCellPlay() {
+ const g = quizGhostAreaGridPlay();
+ if (!g) return null;
+ const cells = [];
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y] || [];
+ for (let x = 0; x < row.length; x++) {
+ if (row[x] === 1 && quizGhostFootprintInsidePlay(x, y)) cells.push({ x: x, y: y });
+ }
+ }
+ if (!cells.length) {
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y] || [];
+ for (let x = 0; x < row.length; x++) if (row[x] === 1) cells.push({ x: x, y: y });
+ }
+ }
+ return cells.length ? cells[Math.floor(Math.random() * cells.length)] : null;
+ }
+
+ /** ตรวจว่า 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 เท่านั้น */
+ /** row ล่างสุดของโซนผี (cache บน mapData) — ใช้ clamp ภาพผีให้อยู่ในโซน */
+ function quizGhostZoneMaxRowPlay() {
+ if (!mapData) return null;
+ if (mapData._ghostMaxRow !== undefined) return mapData._ghostMaxRow;
+ const g = mapData.quizGhostArea;
+ let maxY = null;
+ if (Array.isArray(g)) {
+ for (let y = 0; y < g.length; y++) {
+ const row = g[y] || [];
+ for (let x = 0; x < row.length; x++) { if (Number(row[x]) === 1) maxY = y; }
+ }
+ }
+ mapData._ghostMaxRow = maxY;
+ return maxY;
+ }
+ function _quizGhostScreenAnchorPlay(axTile, ayTile, w2s, zDrawIn) {
+ const md = mapData;
+ const { cw, ch } = getCharacterFootprintWH(md);
+ const cxWorld = (axTile + cw * 0.5) * tileSize;
+ /* anchor เท้าผีตามปกติ (ayTile + ch) แต่ clamp ไม่ให้ต่ำกว่าขอบล่างโซนผี (maxRow + 0.6)
+ → ภาพผีอยู่ "ในโซนผี" พอดี: ไม่ลอยสูงเกิน (เคส ayTile) และไม่ห้อยลงทับ SAFE/SCAM (เคส ayTile+ch) */
+ let bottomTile = ayTile + ch;
+ const maxRow = quizGhostZoneMaxRowPlay();
+ if (maxRow != null) {
+ const clampBottom = maxRow + 0.6;
+ if (bottomTile > clampBottom) bottomTile = clampBottom;
+ }
+ const cyBottomWorld = bottomTile * 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;
+ /** server-auth (เกมจริง): epoch สิ้นสุดนับ 3-2-1 บนโต๊ะ ช่วง read phase (>0 = กำลังโชว์ countdown) */
+ let quizCarrySrvReadCountdownEndAt = 0;
+ /** MG4 server-auth: ไอคอน ✓/✗ ลอยเหนือหัวตอนตอบ (key = entity id → { ok, until }) ทุกจอเห็นพร้อมกัน */
+ const quizCarryEntityFeedbackFx = Object.create(null);
+ const QUIZ_CARRY_FEEDBACK_FX_MS = 1300;
+ /** หมดเวลาตอบรอบนี้แล้ว — โชว์ 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;
+ /** MG4 server-auth (detective): กัน schedule กลับ LobbyB ซ้ำเมื่อ quiz-carry-ended มาหลายครั้ง */
+ let quizCarryDetectiveReturnNavDone = 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;
+ /* เวลา server (epoch ms) ที่ live เริ่มจริง — ใช้คำนวณ scroll/เวลานับถอยหลังจาก serverNow() ร่วมกัน
+ ทุกเครื่องเห็นแพลตฟอร์มเลื่อนเท่ากัน + จบพร้อมกัน (เดิมสะสม dt local → เลื่อน/จบไม่ตรงกัน = desync) */
+ let jumpSurviveLiveAnchorServerMs = 0;
+ let jumpSurvivePlatformScrollPx = 0;
+ let jumpSurviveVy = 0;
+ let jumpSurviveLastTickMs = 0;
+ let jumpSurviveJumpQueued = false;
+ let jumpSurviveOnGround = false;
+ let jumpSurviveLastEmitT = 0;
+ let jumpSurviveHiddenTimer = null; /* แท็บ hidden → rAF หยุด → แจ้ง server ให้จำลองการตกแทน (คนอื่นเห็นลื่น) */
+ let jumpSurviveHiddenLastMs = 0;
+ let jumpSurviveHiddenSent = false; /* แจ้ง server แล้วว่า hidden (กันส่งซ้ำทุก tick) */
+ let jumpSurviveServerAuthPlay = false; /* true เมื่อรับ jump-survive-bot-move/ended ครั้งแรก — server เป็นเจ้าของบอท/รอบ */
+ let spaceShooterServerAuthPlay = false; /* MG6: true เมื่อรับ space-shooter-bot-move — server เป็นเจ้าของบอท/รอบ */
+ let balloonBossServerAuthPlay = false; /* MG7: true เมื่อรับ balloon-boss-bot-move — server เป็นเจ้าของบอท/รอบ/HP บอส */
+ /** 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;
+ /* เวลา server ที่ live เริ่ม + ตัวนับลำดับก้อนที่เกิดแล้ว — ใช้ทำ deterministic spawn ให้ทุกเครื่องตรงกัน */
+ let spaceShooterLiveAnchorServerMs = 0;
+ let spaceShooterSpawnCount = 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;
+ /* ปุ่ม SHOOT บนจอ (MG7) กด 1 ครั้ง = ยิง 1 นัด — ตั้ง flag ให้ tick ยิงตามมุมลูกศร เหมือนกด Space */
+ let balloonBossFireBtnQueued = false;
+ let balloonBossGameEnded = false;
+ let balloonBossHitFx = [];
+ /** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */
+ let balloonBossScorePopups = [];
+ let balloonBossBossFireAcc = 0;
+ /* anchor เวลา server + ตัวนับกระสุนบอส — deterministic boss fire ให้ทุกเครื่องยิงทิศ/เวลาเดียวกัน */
+ let balloonBossLiveAnchorServerMs = 0;
+ let balloonBossBossShotCount = 0;
+ /* MG7 host-authoritative boss HP: host broadcast ดาเมจรวมเป็นระยะ (balloon-boss-sync)
+ → non-host แสดง HP บอสตามค่า host เป๊ะ (null = ยังไม่ได้รับ → fallback ผลรวมท้องถิ่น) */
+ let balloonBossHostBossDmg = null;
+ let balloonBossLastSyncEmit = 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'];
+ /** ยานยิงอุกาบาต (MG6) สีตาม lobby theme 1-8 — Rocket-1..8 ตรงกับ PLAY_LOBBY_THEME_HEX */
+ const SPACE_SHOOTER_ROCKET_THEME_URLS = [1, 2, 3, 4, 5, 6, 7, 8].map(function (n) { return BASE + '/img/ViolentCrime/Rocket-' + n + '.png'; });
+ /** theme index (1-8) ของ entity จากสี body ที่ย้อม (playTint) — ใช้แมป asset สีตามผู้เล่น */
+ function playLobbyThemeIndexForEntityPlay(ent) {
+ const tint = ent && ent.playTint;
+ const body = (tint && typeof tint.body === 'string') ? tint.body : null;
+ const idx = body ? nearestIndexFromHexPlay(body, PLAY_LOBBY_THEME_HEX) : null;
+ return (idx >= 1 && idx <= 8) ? idx : null;
+ }
+
+ 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 = {}; mg1AllDeadEndScheduled = false; }
+ 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();
+ if (window.jdSound) { try { window.jdSound.playSfx(isCorrect ? 'correct' : 'wrong', 'mg4'); } catch (e) { /* ignore */ } }
+ 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 - serverNow()) / 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;
+ /* เกมจริง (ห้องจริงทุกแบบ: คนล้วน/มีบอท): server เป็นเจ้าของตำแหน่ง/เลนของทุกตัวผ่าน gauntlet-sync
+ → ห้าม client จัดเลนเอง เพราะ layout นี้สแนป me ไป pos[0] (เลนบนสุด, จุดสตาร์ท) เสมอ
+ ทำให้ตอน host หลุด me (host ใหม่) ไปทับบอท takeover ที่กินเลนบนสุด
+ เฉพาะ editor preview (preview=1, ไม่มี server ขับ) เท่านั้นที่ client จัดเลนเอง */
+ if (!previewMode) 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;
+ }
+
+ /** ป้ายชื่อบอท "บอท N (tier)" — เรียงตาม id (เหมือนทุกเครื่อง) ใช้ o.botTier (host เป็นเจ้าของ ผ่าน fill-bot-state) */
+ function relabelPreviewBotsPlay() {
+ 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;
+ });
+ }
+
+ /* MG5/6/7 server-auth: server ขับ entity บอทที่ "client นี้ไม่เคยสร้าง" (นับ human ไม่ตรง server /
+ race LobbyB→play / reconnect / ghost) → fill-bot-state ถูกปิดสำหรับเกม server-auth จึงไม่มี self-heal
+ → *-bot-move ทุก packet ถูกดรอป (if(!o)return) = บอทตัวนั้นค้าง/หายเฉพาะ client นี้ (= อาการ MG6).
+ สร้าง preview-bot entity เดี๋ยวนี้ + กำหนด avatar/ป้ายตาม index เรียง (deterministic ทุกเครื่อง).
+ คืน null ถ้าเป็น takeover id (=socket-id ของคนที่ออก) ที่ entity ยังไม่มา → caller รอ join/snapshot. */
+ function ensureServerAuthBotEntityPlay(id, dir) {
+ if (id == null) return null;
+ let o = others.get(id);
+ if (o) return o;
+ if (!isPreviewBotId(id)) return null;
+ o = {
+ x: 1, y: 1, tx: 1, ty: 1,
+ direction: dir || 'down',
+ nickname: 'บอท',
+ characterId: null,
+ playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id),
+ botTier: 'avg',
+ spaceShooterScore: 0, spaceShooterHits: 0, spaceShooterEliminated: false,
+ balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossEliminated: false,
+ jumpSurviveEliminated: false,
+ };
+ try { o.balloonBossBalloons = balloonBossBalloonsStartPlay(); } catch (e) { o.balloonBossBalloons = 5; }
+ others.set(id, o);
+ /* avatar ตาม index เรียง (เหมือน rebalancePreviewBots 5967-5969) → ทุกเครื่องได้ชุด avatar เดียวกัน */
+ [...others.keys()].filter(isPreviewBotId).sort().forEach((bid, bi) => { const bo = others.get(bid); if (bo) bo.characterId = pickPreviewBotCharacterId(bi); });
+ try { relabelPreviewBotsPlay(); } catch (e) { /* ignore */ }
+ try { if (o.characterId) preloadPlayTintForAvatar(o.characterId, o.playTint); } catch (e) { /* ignore */ }
+ return o;
+ }
+
+ function rebalancePreviewBots() {
+ if (!playBotsEnabled() || !mapData) return;
+ const human = countPlayHumans();
+ let wantBots = Math.max(0, playBotTargetHeadcount() - human);
+ wantBots = Math.min(wantBots, Math.max(0, 6 - human)); /* total ≤ 6 เสมอ (กัน over-count เช่น reconnect หลัง bot-takeover) */
+ /* Card 3 Ban — บอทที่ถูกแบนรอบนี้ (slot N) ไม่ลงเล่น: ลบทิ้ง + ลดเป้าจำนวนลง 1 + ข้าม slot นั้นตอนสร้าง */
+ if (playBannedBotSlot >= 0) {
+ others.delete(PREVIEW_BOT_PREFIX + playBannedBotSlot);
+ wantBots = Math.max(0, wantBots - 1);
+ }
+ [...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 === playBannedBotSlot) 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),
+ });
+ /* preload เลเยอร์สี + tint ของบอททันทีตอนสร้าง — กัน score HUD บน host โชว์ avatar บอท "ไม่ย้อมสี"
+ (เดิม preloadPlayOccupantsTints รันตอน scene-load ก่อนบอทถูกสร้าง → เลเยอร์ยังไม่โหลด) */
+ try {
+ const bo = others.get(id);
+ if (bo && bo.characterId) preloadPlayTintForAvatar(bo.characterId, bo.playTint || resolvePlayTintForPeer(id));
+ } catch (ePB) { /* ignore */ }
+ botIds.push(id);
+ }
+ relabelPreviewBotsPlay();
+ [...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;
+ if (detectiveCaseFillBots) return; /* เกมจริง: server ตัดสินคำตอบบอทเอง */
+ 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'; }
+ /* ===== Sound (jdSound) — scene + end-sfx helpers ===== */
+ var JD_SCENE_MAP = { quiz: 'mg1', gauntlet: 'mg2', stack: 'mg3', quiz_carry: 'mg4', jump_survive: 'mg5', space_shooter: 'mg6', balloon_boss: 'mg7' };
+ function jdPlayScene() { var gt = mapData && mapData.gameType; return (gt && JD_SCENE_MAP[gt]) || 'mg1'; }
+ /* เล่นเสียงจบเกมจากชื่อไฟล์แฟลชผล (complete/victory=ชนะ · timeup=หมดเวลา · gameover=แพ้) + หยุด BGM */
+ function jdEndSfxFromFile(f) {
+ if (!window.jdSound) return;
+ f = String(f || ''); var s = jdPlayScene();
+ if (f.indexOf('victory') >= 0 || f.indexOf('complete') >= 0) window.jdSound.playSfx('win', s);
+ else if (f.indexOf('timeup') >= 0) window.jdSound.playSfx('timeup', s);
+ else if (f.indexOf('gameover') >= 0) window.jdSound.playSfx('lose', s);
+ try { window.jdSound.stopBgm(); } catch (e) { /* ignore */ }
+ }
+ /* เสียงคลิกปุ่ม (เฉพาะ element ที่เป็นปุ่มจริง) */
+ document.addEventListener('click', function (e) {
+ if (!window.jdSound) return;
+ var t = (e.target && e.target.closest) ? e.target.closest('button, .btn, [role="button"], [class*="btn-"]') : null;
+ if (t) { try { window.jdSound.playSfx('button', jdPlayScene()); } catch (err) { /* ignore */ } }
+ }, true);
+ 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;
+ /* MG7 (balloon_boss) — ขยายให้เต็มจอกว่า fit ทั้งแมป: ซูมพอดีกับพื้นที่ผู้เล่น+บอส (crop ขอบบน-ล่างที่ว่าง)
+ แมป 20×15 (aspect 1.33) บนจอ 16:9 ถ้า fit ทั้งแมปจะเหลือขอบดำซ้าย-ขวาเยอะ */
+ if (isBalloonBoss()) {
+ const win = getBalloonBossVisibleHalfTilesPlay(mapData);
+ if (win) {
+ const needWpx = win.halfW * 2 * tileSize;
+ const needHpx = win.halfH * 2 * tileSize;
+ const zContent = Math.min(canvas.width / needWpx, canvas.height / needHpx);
+ if (Number.isFinite(zContent) && zContent > zDraw) zDraw = zContent;
+ }
+ }
+ } 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;
+ } else if (isGauntletCrownHeistMapPlay() && !(previewMode && editorEmbedReturn)) {
+ /* #1 map fit: gauntlet เกมจริง — fit "ความสูงแมป (ลู่ทั้งหมด)" ให้พอดีจอ resolution-adaptive
+ (เดิมใช้ zoom ดิบ ไม่ปรับตาม resolution = ไม่พอดี) · fit สูงอย่างเดียว เพราะ runway เลื่อนแนวนอน */
+ const mhPx = h * tileSize;
+ const fitZ = (canvas.height / mhPx) * 1.0; /* เต็มจอบน-ล่าง (เดิม 0.96 = มีขอบดำบน-ล่าง) */
+ if (Number.isFinite(fitZ) && fitZ > 0) zDraw = fitZ;
+ }
+ 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;
+ /* แกน Y ตามผู้เล่นเครื่องตัวเอง (ไม่เฉลี่ยทั้งกลุ่ม) → เลนของเราคงที่ใต้กล้อง, host/คนอื่นอยู่เลนของเขาเอง
+ (เดิมเฉลี่ย Y ทั้งกลุ่ม → ทุกคนถูกเลื่อนจากเลนจริง ทำให้ host ดูมาซ้อนเลนของ client) ; ตายแล้ว → fallback เฉลี่ยกลุ่ม */
+ let py = sumCy / ents.length;
+ if (!me.gauntletEliminated && Number.isFinite(me.y)) {
+ py = (me.y + ch * 0.5) * tileSizeRef;
+ }
+ 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() || !isMePlayHost() || !isGauntletCrownHeistMapPlay() || !mapData) return;
+ if (detectiveCaseFillBots) return; /* MG2 เกมจริง: server ตัดสินการตาย/คัดออกของบอทเอง */
+ 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'; }
+ /* MG5 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอทเอง, ทุกเครื่องรับตำแหน่งบอทจาก server */
+ function jumpSurviveServerAuthActive() {
+ return isJumpSurvive() && (jumpSurviveServerAuthPlay || isDetectiveMinigamePlay());
+ }
+ /* MG6 server-auth: server เป็นเจ้าของบอท/รอบ → host เลิก sim บอท, ทุกเครื่องรับตำแหน่งบอทจาก server */
+ function spaceShooterServerAuthActive() {
+ return isSpaceShooter() && (spaceShooterServerAuthPlay || isDetectiveMinigamePlay());
+ }
+ /* MG7 server-auth: server เป็นเจ้าของบอท/รอบ/HP บอส → host เลิก sim บอท, ทุกเครื่องรับตำแหน่ง/ดาเมจบอทจาก server */
+ function balloonBossServerAuthActive() {
+ return isBalloonBoss() && (balloonBossServerAuthPlay || isDetectiveMinigamePlay());
+ }
+
+ 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';
+ }
+
+ /** Mega Virus mnq1eml7 (MG7) — ช่วงเล่นจริง: ใช้ HUD/score-row แบบเดียวกับ MG6 (qm-mock)
+ กันคะแนน/ชื่อใช้ layout เก่าที่ text ทับ/ซ่อน */
+ function isBalloonBossMissionHudActivePlay() {
+ return isMegaVirusMissionShellMapPlay() && balloonBossSessionStartMs > 0 && !balloonBossGameEnded;
+ }
+
+ 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');
+ }
+ }
+
+ /** กันภาพผลโชว์ "ซ้ำ" — บางเกมจบ 2 path (เช่น MG2 local finish + server gauntlet-ended) ยิง flash 2 รอบผลต่างกัน
+ → ตัวที่ 2 ถูกบล็อก (เก็บตัวแรกไว้). reset เมื่อเริ่มมินิเกมรอบใหม่ (resetMissionResultFlashGuardPlay) */
+ let _missionResultFlashAt = 0;
+ function missionResultFlashGuardPlay() {
+ const now = Date.now();
+ if (now - _missionResultFlashAt < 9500) return false; /* เพิ่งโชว์ไป → บล็อกตัวซ้ำ */
+ _missionResultFlashAt = now;
+ return true;
+ }
+ function resetMissionResultFlashGuardPlay() { _missionResultFlashAt = 0; }
+
+ /** มีใครได้คะแนน ≥1 ไหม (จาก mission.ranked) — ตัดสิน complete/gameover (MG1 quiz / MG4 quiz_carry) */
+ function missionAnyScorePlay(mission) {
+ const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : [];
+ return r.some(function (x) { return x && (Number(x.baseScore) || Number(x.finalScore) || Number(x.score) || 0) > 0; });
+ }
+ /** จำนวนผู้รอด (ไม่ eliminated) — ตัดสิน complete/gameover (MG2 เข้าเส้นชัย / MG5 / MG6) */
+ function missionSurvivorsPlay(mission) {
+ if (mission && typeof mission.survivorCount === 'number') return mission.survivorCount;
+ const r = (mission && Array.isArray(mission.ranked)) ? mission.ranked : [];
+ return r.filter(function (x) { return x && !x.eliminated; }).length;
+ }
+ /** จำนวนผู้เข้าร่วมทั้งหมด (คน+บอท) */
+ function missionParticipantsPlay(mission) {
+ if (mission && typeof mission.participantCount === 'number' && mission.participantCount > 0) return mission.participantCount;
+ return (mission && Array.isArray(mission.ranked)) ? mission.ranked.length : 0;
+ }
+ /** "รอดทั้งหมด" — ทุกคนรอด ไม่มีใคร eliminated (MG5/MG6) */
+ function missionAllSurvivedPlay(mission) {
+ const total = missionParticipantsPlay(mission);
+ return total > 0 && missionSurvivorsPlay(mission) >= total;
+ }
+ /** แฟลชรูปผลรูปเดียว (5s) แล้วเปิด GCM — เกมที่เดิมไม่มีแฟลช (MG1/MG2). asset ตาม skin ผ่าน gcmMissionMockAssetUrl */
+ function beginSimpleResultFlashThenGcmPlay(mission, file) {
+ if (!missionResultFlashGuardPlay()) return; /* กันโชว์ซ้ำ (MG2: local finish + server gauntlet-ended ยิงทั้งคู่) */
+ hideStackTowerResultFlashDomOnlyPlay();
+ const ov = document.getElementById('stack-tower-result-flash');
+ const imgEl = document.getElementById('stack-tower-result-flash-img');
+ if (!mission || !ov || !imgEl) { showGauntletCrownMissionOverlay(mission); return; }
+ const finishToGcm = function () { hideStackTowerResultFlashDomOnlyPlay(); showGauntletCrownMissionOverlay(mission); };
+ imgEl.onerror = function () { imgEl.onerror = null; finishToGcm(); };
+ jdEndSfxFromFile(file);
+ imgEl.src = gcmMissionMockAssetUrl(mission, file);
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; finishToGcm(); }, STACK_TOWER_RESULT_FLASH_MS);
+ }
+
+ /** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */
+ function beginStackTowerResultFlashThenGcm(mission) {
+ if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */
+ 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();
+ };
+ jdEndSfxFromFile(file);
+ 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: มีคนรอด ≥1 = complete · ไม่รอดเลย = gameover (เกรดไม่เกี่ยว — ตามกฎใหม่) */
+ function jumpSurviveMissionOutcomeImageFile(mission) {
+ /* รอดทั้งหมด = complete · มีคนตาย/ตายหมด = gameover (ตามกฎใหม่) */
+ return missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png';
+ }
+
+ /**
+ * Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome;
+ * all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM
+ */
+ function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) {
+ if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */
+ 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();
+ };
+ jdEndSfxFromFile(file);
+ 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 (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */
+ 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();
+ };
+ jdEndSfxFromFile(file);
+ 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();
+ /* หมดเวลา: รอดทั้งหมด = complete · มีคนตาย = gameover (ตามกฎใหม่) */
+ showOneFlash(missionAllSurvivedPlay(mission) ? 'result-complete.png' : 'result-gameover.png', finishToGcm);
+ });
+ } else {
+ showOneFlash('result-gameover.png', finishToGcm); /* all_dead */
+ }
+ }
+
+ /**
+ * Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM
+ * — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow)
+ * — ชนะ = end-victory-2.png ใน MegaVirus
+ */
+ function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) {
+ if (!missionResultFlashGuardPlay()) return; /* กันโชว์ผลซ้ำ */
+ 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 finishToGcm = function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showGauntletCrownMissionOverlay(mission);
+ };
+ const showOneFlash = function (url, onDone) {
+ imgEl.onerror = function () { imgEl.onerror = null; onDone(); };
+ jdEndSfxFromFile(url);
+ imgEl.src = url;
+ ov.classList.remove('is-hidden');
+ ov.setAttribute('aria-hidden', 'false');
+ stackTowerResultFlashTimer = setTimeout(function () { stackTowerResultFlashTimer = null; onDone(); }, STACK_TOWER_RESULT_FLASH_MS);
+ };
+ if (endReason === 'victory') {
+ /* ฆ่าบอส HP=0 → end-victory-2.png (MegaVirus) */
+ showOneFlash(megaVirusAssetUrl('end-victory-2.png'), finishToGcm);
+ } else if (endReason === 'time') {
+ /* หมดเวลา บอสยังไม่ตาย → result-timeup → result-gameover (MegaVirus ไม่มี timeup ใช้ TowerBlock) */
+ showOneFlash(stackTowerAssetUrl('result-timeup.png'), function () {
+ hideStackTowerResultFlashDomOnlyPlay();
+ showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm);
+ });
+ } else {
+ /* ตายหมด → result-gameover */
+ showOneFlash(stackTowerAssetUrl('result-gameover.png'), finishToGcm);
+ }
+ }
+
+ 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;
+ applyGauntletCrownReadyButtonPlay(st, btn);
+ }
+
+ /** 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;
+ applyGauntletCrownReadyButtonPlay(st, btn);
+ }
+
+ /** 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;
+ applyGauntletCrownReadyButtonPlay(st, btn);
+ }
+
+ 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;
+ hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */
+ 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);
+ runAlignedCountdownPlay(
+ function (n) { setCountdown321QuestionAssetGraphic(numEl, n); },
+ runFinish,
+ function (id) { stackTowerMissionCountdownTimer = id; }
+ );
+ }
+
+ 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) return;
+ if (entry.kind === 'bot' && entry.botId) {
+ 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 (entry.kind === 'human' && entry.id != null && String(entry.id) !== String(myId)) {
+ /* คนจริงคนอื่น — ใส่ในสรุปด้วย ใช้คะแนนเฉพาะตัว (เดิมมีแต่ me+bots) */
+ const oh = others.get(entry.id) || others.get(String(entry.id));
+ pushEnt(String(entry.id), oh ? (oh.nickname || String(entry.id).slice(0, 6)) : String(entry.id).slice(0, 6), oh ? oh.characterId : null, oh ? (oh.stackPreviewHumanPts || 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, 6);
+ 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, 6);
+ 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;
+ if (stackServerAuthPlay) return; /* server-auth: server ตัดสินจบ + ส่ง stack-over */
+ 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;
+ let outcome = 'time_up';
+ if (pct >= 100) outcome = 'decrypt_complete';
+ else if (missesOut) outcome = 'misses_exhausted';
+ /* เล่นจริง: host เป็นคนตัดสินจบ + broadcast → ทุกเครื่องจบพร้อมกัน (เดิมต่างคนต่างจับเวลา local → จบไม่พร้อม) */
+ if (detectiveCaseFillBots && !isMePlayHost()) return; /* non-host รอ stack-over จาก host */
+ if (detectiveCaseFillBots && socket && myId != null) {
+ try { socket.emit('stack-over', { outcome: outcome }); } catch (e) { /* ignore */ }
+ }
+ finishStackTowerMissionLocalPlay(outcome);
+ }
+
+ /** จบรอบ MG3 จริง (host เรียกเอง / non-host เรียกตอนรับ stack-over) */
+ function finishStackTowerMissionLocalPlay(outcome) {
+ if (stackTowerMissionEndedOnce || !isStackTowerMissionUiMapPlay()) return;
+ stackTowerMissionEndedOnce = true;
+ stackTowerMissionPhase = 'ended';
+ applyStackTowerMissionPanelImages();
+ beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome || 'time_up' }));
+ }
+
+ 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;
+ /* นับเวลาจาก anchor server (serverNow) → จบพร้อมกันทุกเครื่อง; fallback local ถ้ายังไม่มี anchor */
+ const elapsed = jumpSurviveLiveAnchorServerMs > 0
+ ? (serverNow() - jumpSurviveLiveAnchorServerMs) / 1000
+ : (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;
+ hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */
+ 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;
+ /* anchor เวลาเริ่ม live = liveBeginsAt ของ server (ถ้ามี) → scroll/timer ของทุกเครื่องอ้างอิงจุดเดียวกัน */
+ jumpSurviveLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow();
+ jumpSurvivePlatformScrollPx = 0;
+ applyJumpSurvivePreviewSpawnLayout(false);
+ };
+ if (!cd || !numEl) {
+ runFinish();
+ return;
+ }
+ cd.classList.remove('is-hidden');
+ syncGauntletCrownCountdownMg2ModePlay(cd);
+ runAlignedCountdownPlay(
+ function (n) { setCountdown321QuestionAssetGraphic(numEl, n); },
+ runFinish,
+ function (id) { jumperMissionCountdownTimer = id; }
+ );
+ }
+
+ /** 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, 6);
+ 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;
+ hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */
+ 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;
+ spaceShooterSpawnCount = 0;
+ /* anchor = liveBeginsAt ของ server (ทุกเครื่องได้ค่าเดียวกัน) → spawn/timer อ้างอิงจุดเดียวกัน */
+ spaceShooterLiveAnchorServerMs = playMissionLiveBeginsAt > 0 ? playMissionLiveBeginsAt : serverNow();
+ 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);
+ runAlignedCountdownPlay(
+ function (n) { setCountdown321QuestionAssetGraphic(numEl, n); },
+ runFinish,
+ function (id) { spaceShooterMissionCountdownTimer = id; }
+ );
+ }
+
+ 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, 6);
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ /* โบนัสอยู่รอดจนจบเกม = +20 (เฉพาะคนไม่ตาย) — รวมใน finalScore เหมือน rankBonus ของ MG2 */
+ const surviveBonus = row.eliminated ? 0 : 20;
+ 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: surviveBonus,
+ finalScore: row.baseScore + surviveBonus,
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.finalScore; });
+ 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, 6);
+ 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, 6);
+ /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */
+ const endRBonus = mission && mission.balloonBossEndReason;
+ const killBonusAll = endRBonus === 'victory' ? 100 : 0;
+ const ranked = top.map(function (row, idx) {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ const bonus = killBonusAll + (row.eliminated ? 0 : 10);
+ 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: bonus,
+ finalScore: Math.max(0, bs + bonus),
+ };
+ });
+ const totalParts = ranked.map(function (r) { return r.finalScore; });
+ 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, 6);
+ /* โบนัสจบเกม: ฆ่าบอส(+100)→ทุกคนที่ร่วมเล่น · รอดชีวิต(+10)→เฉพาะคนไม่ตาย (รวมใน finalScore, clamp ≥0) */
+ const killBonusAll = reason === 'victory' ? 100 : 0;
+ const ranked = top.map((row, idx) => {
+ const pos = idx + 1;
+ const bs = Math.max(0, Number(row.baseScore) || 0);
+ const bonus = killBonusAll + (row.eliminated ? 0 : 10);
+ 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: bonus,
+ finalScore: Math.max(0, bs + bonus),
+ };
+ });
+ const totalParts = ranked.map((r) => r.finalScore);
+ 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,
+ };
+ }
+
+ /* host เป็นผู้ตัดสินจบ MG6 + แนบคะแนน/สถานะสุดท้าย → ทุกเครื่องจบพร้อมกันและอันดับตรงกัน */
+ function triggerSpaceShooterOverPlay(kind) {
+ if (spaceShooterGameEnded) return;
+ const k = (kind === 'all_dead') ? 'all_dead' : 'time_up';
+ try {
+ if (socket && myId != null && isMePlayHost()) {
+ const scores = buildStableShipParticipantRefsPlay().map((e) => ({
+ id: e.id,
+ score: Math.max(0, (e.ref && (e.ref.spaceShooterScore | 0)) || 0),
+ hits: Math.max(0, Number(e.ref && e.ref.spaceShooterHits) || 0),
+ eliminated: !!(e.ref && e.ref.spaceShooterEliminated),
+ }));
+ socket.emit('space-shooter-over', { kind: k, scores: scores });
+ }
+ } catch (e) { /* ignore */ }
+ endSpaceShooterMissionRound(k);
+ }
+
+ function applySpaceShooterOverScoresPlay(scores) {
+ if (!Array.isArray(scores)) return;
+ scores.forEach((s) => {
+ if (!s || s.id == null) return;
+ const sc = Math.max(0, Number(s.score) || 0);
+ const hits = Math.max(0, Number(s.hits) || 0);
+ const ent = (myId != null && String(s.id) === String(myId)) ? me : (others.get(String(s.id)) || others.get(s.id));
+ if (!ent) return;
+ ent.spaceShooterScore = sc;
+ ent.spaceShooterHits = hits;
+ if (s.eliminated) ent.spaceShooterEliminated = true;
+ });
+ }
+
+ 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') + "?v=3')");
+ }
+ 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) {
+ /* MG1 ใช้ popup-Howtominigame1.png (รูป SAFE/SCAM) — popup-Howto.png เดิมเป็นดีไซน์ MG4 ใส่ผิดที่ */
+ howtoBg.src = questionMissionAssetUrl('popup-Howtominigame1.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;
+ hideReadyAvatarsFloat(); /* ซ่อนแถวหน้าตัวละคร ready ตอนเริ่มเกม (กันค้างบนจอระหว่างเล่น) */
+ 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);
+ runAlignedCountdownPlay(
+ function (n) { setCountdown321QuestionAssetGraphic(numEl, n); },
+ runFinish,
+ function (id) { quizQuestionMissionCountdownTimer = id; }
+ );
+ }
+
+ 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, 6);
+ 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';
+ /* เกรด F (= โชว์ Lose stamp) เฉพาะ "ไม่มีใครได้คะแนนเลย" — ดูจาก playLiveQuizScores เต็ม (ครบทุกคน)
+ ไม่ใช่ totalSum ของ ranked (me+others) ที่อาจหายคนตอบถูกที่ไม่อยู่ใน others → ขัดกับ flash complete */
+ const mg1FullScoreSum = Object.keys(playLiveQuizScores).reduce(function (s, k) { return s + Math.max(0, playLiveQuizScores[k] | 0); }, 0);
+ if (mg1FullScoreSum <= 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, 6);
+ 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'; }
+
+ /* MG4 server-authoritative: เกมจริง (detective) เท่านั้น — server เป็นเจ้าของ คำถาม/เฟส/หยิบ-ส่ง/คะแนน/บอท
+ editor preview (เล่นเดี่ยว) ยังเป็น client-sim เดิม (gate เดียวกับ server: space.detectiveMinigameActive) */
+ let quizCarryServerAuthPlay = false; /* ตั้ง true เมื่อรับ quiz-carry-phase (srv) ครั้งแรก — กัน edge ก่อน flag */
+ function quizCarryServerAuthActive() {
+ return isQuizCarry() && (quizCarryServerAuthPlay || isDetectiveMinigamePlay());
+ }
+
+ /** พรีวิว 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 quizCarryWrapLinesFit(ctx, text, maxWidth) {
+ const raw = String(text || '').trim();
+ if (!raw) return [];
+ const lines = [];
+ let cur = '';
+ for (const chr of raw) {
+ if (chr === '\n') { lines.push(cur); cur = ''; continue; }
+ const test = cur + chr;
+ if (cur === '' || ctx.measureText(test).width <= maxWidth) cur = test;
+ else { lines.push(cur); cur = chr; }
+ }
+ if (cur) 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);
+ }
+
+ /* ผู้ถูกแบนรอบนี้ (Card 3 Ban) เข้ามาดูเฉยๆ ไม่ต้อง Ready → ไม่นับใน gate เริ่มเกม
+ (ไม่งั้น host จะค้าง N/N+1 เพราะ banned ไม่เคย ready) */
+ function isQuizCarryPregameBannedId(id) {
+ const o = others.get(id) || others.get(String(id));
+ return !!(o && o.bannedSpectator);
+ }
+
+ function quizCarryPregameHumanIds() {
+ const set = new Set();
+ if (myId != null && !isPreviewBotId(myId) && !playBannedSpectator) set.add(String(myId));
+ /* นับจาก readyMap ของ server (authoritative + ตัด banned แล้ว) + ตัวเราเท่านั้น
+ → host/client นับ "จำนวนผู้เล่น" ตรงกันเสมอ (เดิมวน others ซึ่ง membership ต่างกันชั่วคราว → ready ไม่ตรง)
+ การรอให้ client เข้าครบใช้ expectedActive (gauntletCrownPregameExpectedHumans) แทน */
+ [quizCarryLobbyReadyMap, gauntletCrownLobbyReadyMap].forEach((rm) => {
+ if (rm && typeof rm === 'object') {
+ Object.keys(rm).forEach((id) => {
+ if (id && !isPreviewBotId(id) && !isQuizCarryPregameBannedId(id)) set.add(String(id));
+ });
+ }
+ });
+ return [...set];
+ }
+
+ 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; /* fallback (load-bearing): host พึ่งตอน null — อย่าเปลี่ยน (เคยทำ MG3 ready ค้าง 4/5 + MG2 spawn ทับ) */
+ return String(playHostId) === String(myId);
+ }
+
+ /* บอทเติมช่อง (fill-bot) ใน 7 มินิเกมต้องเดิน/เล่น/คะแนนตรงกันทุก client:
+ host เป็นผู้จำลอง bot ทั้งหมด (AI/ฟิสิกส์/คะแนน) แล้ว broadcast state — client อื่นปิด local sim แล้ว render ตาม
+ (เดิม: ทุกเครื่องสุ่ม Math.random() แยกกัน → บอทตัวเดียวกันอยู่คนละที่/คนละคะแนนบนแต่ละจอ) */
+ function fbNum(v) { return (typeof v === 'number' && Number.isFinite(v)) ? Math.round(v * 100) / 100 : undefined; }
+ function broadcastFillBotState() {
+ if (!playBotsEnabled() || !isMePlayHost() || !mapData) return;
+ if (isQuiz() && detectiveCaseFillBots) return; /* quiz เกมจริง: server เป็นเจ้าของบอท (quiz-bot-move) → ห้าม host ส่งทับ = บอทวาป/ผีหลุด area */
+ if (isGauntlet() && detectiveCaseFillBots) return; /* MG2 เกมจริง: server เป็นเจ้าของบอท (gauntlet-sync) → ห้าม host ส่งทับ */
+ if (quizCarryServerAuthActive()) return; /* MG4 เกมจริง: server เป็นเจ้าของบอท (quiz-carry-bot-move) → ห้าม host ส่งทับ */
+ if (jumpSurviveServerAuthActive()) return; /* MG5 เกมจริง: server เป็นเจ้าของบอท (jump-survive-bot-move) → ห้าม host ส่งทับ */
+ if (spaceShooterServerAuthActive()) return; /* MG6 เกมจริง: server เป็นเจ้าของบอท (space-shooter-bot-move) → ห้าม host ส่งทับ */
+ if (balloonBossServerAuthActive()) return; /* MG7 เกมจริง: server เป็นเจ้าของบอท (balloon-boss-bot-move) → ห้าม host ส่งทับ */
+ const arr = [];
+ others.forEach((o, id) => {
+ if (!isPreviewBotId(id) || !o) return;
+ const row = { id: id, x: fbNum(o.x), y: fbNum(o.y), direction: o.direction || 'down', walking: !!o.botIsWalking };
+ if (o.characterId != null) row.cid = o.characterId; /* ให้ client สร้างบอทที่ขาดได้ → ชุดบอท host-authoritative */
+ if (o.botTier != null) row.tier = o.botTier; /* tier (ฉลาด/กลาง/พลาดบ่อย) — host เป็นเจ้าของ ให้ label/พฤติกรรมตรงกันทุกเครื่อง (เดิมสุ่ม local → ป้ายไม่ตรง) */
+ if (o.gauntletScore != null) row.gScore = o.gauntletScore;
+ if (o.gauntletEliminated != null) row.gElim = !!o.gauntletEliminated;
+ if (o.gauntletJumpTicks != null) row.gJump = o.gauntletJumpTicks;
+ if (o.gauntletJumpVis != null) row.gJumpVis = fbNum(o.gauntletJumpVis);
+ if (o.jumpSurviveEliminated != null) row.jsElim = !!o.jumpSurviveEliminated;
+ if (o.spaceShooterCx != null) row.ssCx = fbNum(o.spaceShooterCx);
+ if (o.spaceShooterCy != null) row.ssCy = fbNum(o.spaceShooterCy);
+ if (o.spaceShooterScore != null) row.ssScore = o.spaceShooterScore;
+ if (o.spaceShooterEliminated != null) row.ssElim = !!o.spaceShooterEliminated;
+ if (o.balloonBossCx != null) row.bbCx = fbNum(o.balloonBossCx);
+ if (o.balloonBossCy != null) row.bbCy = fbNum(o.balloonBossCy);
+ if (o.balloonBossScore != null) row.bbScore = o.balloonBossScore;
+ if (o.balloonBossBossDmg != null) row.bbDmg = o.balloonBossBossDmg;
+ if (o.balloonBossBalloons != null) row.bbBalloons = o.balloonBossBalloons;
+ if (o.balloonBossEliminated != null) row.bbElim = !!o.balloonBossEliminated;
+ if (o.quizCarryHeld !== undefined && !(isQuizCarry() && quizCarryServerAuthActive())) row.qcHeld = o.quizCarryHeld;
+ /* MG1 quiz: สถานะล็อก (ตอบผิด=ผี) + คะแนนบอท → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน
+ (เดิมไม่ส่งเลย → non-host เห็นบอทล็อก/respawn/คะแนนคนละแบบ) */
+ if (isQuiz()) {
+ if (o.quizCannotTrue != null) row.qct = !!o.quizCannotTrue;
+ if (o.quizCannotFalse != null) row.qcf = !!o.quizCannotFalse;
+ }
+ /* คะแนนบอท MG1 (quiz) + MG4 (quiz_carry) → host เป็นเจ้าของ ส่งให้ทุกเครื่องตรงกัน
+ (เดิม MG4 ไม่ส่ง → non-host sim บอทเอง ด้วย RNG ต่างกัน = คะแนนบอทไม่ตรง 2 จอ) */
+ if (isQuiz() || isQuizCarry()) {
+ const qsc = playLiveQuizScores[id];
+ if (qsc != null) row.qsc = Math.max(0, qsc | 0);
+ }
+ /* สีบอท (host เป็นเจ้าของ) — แนบไปด้วยเพื่อให้ทุก client แสดงสีตรงกัน
+ (เดิมแต่ละเครื่องคำนวณสีเองจาก theme/sessionStorage → สีบอทไม่ตรงกันเมื่อ sync ธีมมาช้า) */
+ if (o.playTint && o.playTint.body) {
+ row.tint = { head: o.playTint.head, hair: o.playTint.hair, body: o.playTint.body };
+ }
+ arr.push(row);
+ });
+ /* รายชื่อ "คนจริง" ที่ host เห็น (= myId(host) + others ที่ไม่ใช่บอท) — ให้ non-host force ลบผีที่ host ไม่มี
+ (humans ไม่ถูก sync แบบบอท → ผี reconnect ค้างบน client; id-based ไม่ชนชื่อ default) */
+ const humanRoster = [];
+ if (myId != null) humanRoster.push(String(myId));
+ others.forEach((o, id) => { if (!isPreviewBotId(id) && o) humanRoster.push(String(id)); });
+ if (arr.length) socket.emit('fill-bot-state', { bots: arr, humans: humanRoster });
+ }
+ setInterval(broadcastFillBotState, 80); /* ~12.5Hz — self-gate ถ้าไม่ใช่ host หรือไม่มีบอท */
+
+ /* ===== Server-time sync + เวลาเริ่มเล่นจริงร่วมกัน (liveBeginsAt) =====
+ แก้ปัญหา "เวลาในเกมไม่เท่ากัน เริ่ม/จบไม่พร้อมกัน": เดิมแต่ละเครื่องนับ 3-2-1 เองด้วย setTimeout
+ และจับเวลารอบด้วย performance.now() ของตัวเอง → GO และจุดจบไม่ตรงกัน
+ แก้: sync นาฬิกากับ server (serverNow) แล้วทุกคนนับไปหา "liveBeginsAt" (server epoch) จุดเดียวกัน
+ เมื่อ GO พร้อมกัน → นาฬิการอบ (performance.now ตั้งแต่ GO) ก็ sync กันเองอัตโนมัติ */
+ let playClockOffset = 0; /* serverTime - clientTime (ms) */
+ let playClockBestRtt = null;
+ let playClockSyncInterval = null;
+ let playClockEstablished = false; /* sync ครั้งแรกแล้วหรือยัง — ครั้งแรก hard-set, re-sync ถัดไป blend (กันกระโดด) */
+ function serverNow() { return Date.now() + playClockOffset; }
+ function syncPlayClock(round) {
+ round = round || 0;
+ if (!socket || !socket.connected) return;
+ /* เริ่ม burst ใหม่ทุกครั้ง → รีเซ็ต best RTT เพื่อ "re-establish" offset ใหม่ทั้งหมด
+ ไม่งั้น best RTT ที่ค้างไว้จะทำให้ re-sync ครั้งหลังถูกข้าม แก้ clock drift ไม่ได้
+ (เวลาในเกม MG6/MG7 จะเพี้ยนสะสมเพราะ serverNow ของแต่ละเครื่องไม่ตรงกัน) */
+ if (round === 0) playClockBestRtt = null;
+ const t0 = Date.now();
+ socket.emit('time-sync', (res) => {
+ const t1 = Date.now();
+ if (res && Number.isFinite(res.t)) {
+ const rtt = t1 - t0;
+ if (playClockBestRtt == null || rtt <= playClockBestRtt) {
+ playClockBestRtt = rtt;
+ const newOffset = res.t + rtt / 2 - t1; /* ชดเชยครึ่งหนึ่งของ round-trip latency */
+ if (!playClockEstablished) {
+ playClockOffset = newOffset; /* ครั้งแรก: ตั้งตรง ๆ ให้ baseline แม่น */
+ playClockEstablished = true;
+ } else {
+ /* re-sync ถัดไป: เกลี่ยเข้าค่าใหม่แบบนุ่ม → serverNow ไม่กระโดดทุก 12 วิ (= ไม่ทำเฟส MG3/timer สะดุด).
+ ก้าวใหญ่ >250ms = สแน็ป (drift จริง/เปลี่ยน server) กัน lag ค้าง; ก้าวเล็ก = blend 25% */
+ const dOff = newOffset - playClockOffset;
+ playClockOffset += (Math.abs(dOff) > 250) ? dOff : dOff * 0.25;
+ }
+ }
+ }
+ if (round < 5) setTimeout(function () { syncPlayClock(round + 1); }, 300);
+ });
+ }
+ /* re-sync นาฬิกาเป็นระยะ → กัน clock drift ระหว่างเล่นยาว ๆ ทำให้ serverNow ทุกเครื่องตรงกันเสมอ
+ (ฐานเวลาเดียวกัน → timer/boss/aim/spawn ของทุก minigame sync กัน) */
+ function startPeriodicClockSync() {
+ if (playClockSyncInterval) return;
+ playClockSyncInterval = setInterval(function () {
+ try { if (socket && socket.connected) syncPlayClock(0); } catch (_e) { /* ignore */ }
+ }, 12000);
+ }
+
+ /* ───────────────────────────────────────────────────────────────────────
+ ตัวบอกสถานะเน็ต (เพิ่มล้วน — ไม่แตะ game logic): ป้าย "เน็ตหน่วง"/"กำลังเชื่อมต่อใหม่"
+ แสดงมุมขวาบน. วัด RTT เองด้วย time-sync (server มี handler อยู่แล้ว ไม่ต้องแก้ server)
+ ───────────────────────────────────────────────────────────────────────── */
+ const NET_RTT_WARN_MS = 320; /* เกินนี้ = เน็ตหน่วง (เหลือง) */
+ const NET_RTT_BAD_MS = 700; /* เกินนี้ = แย่มาก (แดง) */
+ let netStatusEl = null;
+ let netLastRtt = null;
+ let netReconnecting = false;
+ let netPingTimer = null;
+ let netOkHideTimer = null;
+ function ensureNetStatusEl() {
+ if (netStatusEl) return netStatusEl;
+ const css = document.createElement('style');
+ css.textContent = ''
+ + '#play-net-status{position:fixed;top:10px;right:12px;z-index:9400;display:none;align-items:center;gap:7px;'
+ + 'padding:7px 13px;border-radius:11px;font-family:inherit;font-weight:700;font-size:14px;line-height:1;'
+ + 'color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.42);pointer-events:none;backdrop-filter:blur(2px);'
+ + 'transition:opacity .25s;letter-spacing:.2px;}'
+ + '#play-net-status.is-warn{background:linear-gradient(135deg,#c9831a,#e0a52b);}'
+ + '#play-net-status.is-bad{background:linear-gradient(135deg,#b3231d,#d84038);}'
+ + '#play-net-status.is-ok{background:linear-gradient(135deg,#1b9e4b,#13c06a);}'
+ + '#play-net-status .pns-dot{width:9px;height:9px;border-radius:50%;background:#fff;opacity:.92;}'
+ + '#play-net-status.is-bad .pns-spin{width:13px;height:13px;border:2px solid rgba(255,255,255,.4);'
+ + 'border-top-color:#fff;border-radius:50%;animation:pnsSpin .8s linear infinite;}'
+ + '@keyframes pnsSpin{to{transform:rotate(360deg);}}';
+ document.head.appendChild(css);
+ const el = document.createElement('div');
+ el.id = 'play-net-status';
+ el.innerHTML = '';
+ document.body.appendChild(el);
+ netStatusEl = el;
+ return el;
+ }
+ function setNetStatus(kind, text, withSpin) {
+ const el = ensureNetStatusEl();
+ if (netOkHideTimer) { clearTimeout(netOkHideTimer); netOkHideTimer = null; }
+ if (kind === 'hidden') { el.style.display = 'none'; return; }
+ el.className = 'is-' + kind;
+ const icon = el.querySelector('.pns-icon');
+ if (icon) icon.innerHTML = withSpin ? '' : '';
+ const t = el.querySelector('.pns-text');
+ if (t) t.textContent = text;
+ el.style.display = 'flex';
+ el.style.opacity = '1';
+ if (kind === 'ok') {
+ netOkHideTimer = setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { if (netStatusEl) netStatusEl.style.display = 'none'; }, 280); }, 1600);
+ }
+ }
+ function refreshNetStatus() {
+ if (netReconnecting || (socket && socket.connected === false)) {
+ setNetStatus('bad', 'กำลังเชื่อมต่อใหม่…', true);
+ return;
+ }
+ if (netLastRtt != null && netLastRtt >= NET_RTT_BAD_MS) {
+ setNetStatus('bad', 'เน็ตหน่วงมาก (' + Math.round(netLastRtt) + ' ms)', false);
+ } else if (netLastRtt != null && netLastRtt >= NET_RTT_WARN_MS) {
+ setNetStatus('warn', 'เน็ตหน่วง (' + Math.round(netLastRtt) + ' ms)', false);
+ } else {
+ setNetStatus('hidden');
+ }
+ }
+ function netPingOnce() {
+ if (!socket || !socket.connected) return;
+ const t0 = Date.now();
+ let done = false;
+ try {
+ socket.emit('time-sync', function () {
+ if (done) return; done = true;
+ netLastRtt = Date.now() - t0;
+ refreshNetStatus();
+ });
+ } catch (_e) { /* ignore */ }
+ setTimeout(function () { if (!done) { done = true; netLastRtt = Math.max(netLastRtt || 0, NET_RTT_BAD_MS); refreshNetStatus(); } }, 2500);
+ }
+ function setupNetStatusIndicator() {
+ if (netPingTimer) return;
+ try {
+ socket.on('disconnect', function () { netReconnecting = true; refreshNetStatus(); });
+ socket.on('connect', function () {
+ if (netReconnecting) { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); }
+ else { netReconnecting = false; }
+ netPingOnce();
+ });
+ if (socket.io && socket.io.on) {
+ socket.io.on('reconnect_attempt', function () { netReconnecting = true; refreshNetStatus(); });
+ socket.io.on('reconnect', function () { netReconnecting = false; setNetStatus('ok', 'เชื่อมต่อแล้ว', false); });
+ }
+ } catch (_e) { /* ignore */ }
+ netPingTimer = setInterval(netPingOnce, 4000);
+ netPingOnce();
+ }
+
+ /* #20 แสดงเลข version มุมขวาล่าง — อ่านจาก ?v= ของ
-
+
+
v —
+
+
+
+
+
+
+
+
วิธีเล่นเบื้องต้น
+

+
+
+
+
+
+
+
+
ข้อเสนอลับ ตัวป่วน
+

+
+
+
15
+
+
+
+
+
+
เลือกผู้ต้องสงสัย
+
+
+
+

+
+
+
+
+
+
เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน
+
+
+
+
+
+
+
+
+
เลือกระดับและคดี
+
+
+
+

+
เลือกระดับความท้าทายตามความเชี่ยวชาญของคุณ
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
TIME : —
+
- Quiz — -
+
+
+
+
+
+
+
+
+
+
+
ช่องเขียว · กด F / แป้นไทย ด โต้ตอบ
+
+ PLAYERS : 0/10
+
+
+
+
+
+
+
+
+
+
![ตัวละครของคุณ]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 150
+
+
+
![ตัวละครผู้เล่น]()
+
+
PLAYER
+
+
+

+
AGENT ID : 001998
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+
+
+
+
+

+
+
+

+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
สรุปจำนวน : 0 ผู้เล่น + 0 Bot
+

+
+

+
+
+
+
+
+
+
+
+
+ Room ID :
+ —
+ 🔒
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
รอ host เลือกฉากและเริ่มเกม
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ v —
+
+