Files
justice/www/html/Game/public/play.js
T

11675 lines
489 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const BASE = '/Game';
/** แผงคำถามบนแมป 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');
const nick = params.get('nick') || 'ผู้เล่น';
const previewMode = params.get('preview') === '1';
const forceDefaultCharacter = params.get('defaultChar') === '1';
const editorEmbedReturn = params.get('editorEmbed') === '1';
const playMapIdFromQuery = (params.get('map') || '').trim();
/** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
let playSessionMapId = playMapIdFromQuery;
/** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
const quizCarryMissionSummaryMapId = 'mnorwqx1';
const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
/** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
/** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
/** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
const LEGACY_PLACEHOLDER_CHARACTER_ID = '';
let firstCharacterDefaultResolved = null;
const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
.then((r) => r.json())
.then((list) => {
if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
firstCharacterDefaultResolved = String(list[0].id);
} else {
firstCharacterDefaultResolved = '';
}
})
.catch(() => {
firstCharacterDefaultResolved = '';
});
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);
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;
try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
const go = function () {
window.location.href = buildRoomLobbyReturnHref();
};
if (socket && socket.connected) {
socket.emit('detective-minigame-finished', {}, function (res) {
if (res && res.ok) go();
else go();
});
} else {
go();
}
}
/** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
const previewFillBots = previewMode && editorEmbedReturn;
const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
let detectiveCaseFillBots = false;
let detectiveBotSlotCount = 0;
function playBotsEnabled() {
return previewFillBots || detectiveCaseFillBots;
}
function playBotTargetHeadcount() {
if (previewFillBots) return previewTargetHeadcount;
if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount;
return countPlayHumans();
}
const PREVIEW_BOT_PREFIX = '__pv_bot_';
let previewBotSeq = 0;
/** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
let lastStackPreviewActorId = null;
let lastStackPreviewActorUntil = 0;
/** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
let stackTurnCount = 6;
let stackPreviewTurnOrder = null;
let stackPreviewTurnIndex = 0;
let stackPreviewBotThinkUntil = 0;
function rebuildStackPreviewTurnOrder() {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
stackPreviewTurnOrder = null;
stackTurnCount = 1;
return;
}
const bots = [...others.keys()].filter(isPreviewBotId).sort();
stackTurnCount = Math.max(1, Math.min(12, playBotTargetHeadcount()));
stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
for (let i = 0; i < stackTurnCount - 1; i++) {
stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
}
stackPreviewTurnIndex = 0;
stackPreviewBotThinkUntil = 0;
}
function advanceStackPreviewTurn() {
if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return;
stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % stackTurnCount;
stackPreviewBotThinkUntil = 0;
}
function isStackPreviewHumanTurn() {
if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return true;
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
return !!(cur && cur.kind === 'human');
}
function getStackPreviewCurrentSeat() {
if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount) return 1;
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
}
if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; }
const socket = io({ path: BASE + '/socket.io' });
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let mapData = null, tileSize = 32, myId = null;
let stackMini = null;
let stackFall = null;
let lastStackTickMs = performance.now();
let mapBackgroundImg = null;
/** โหลดจาก 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 };
const others = new Map();
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
const STACK_PREVIEW_HUD_LOG_MAX = 8;
let stackPreviewHudLog = [];
function stackPreviewPushHudLog(line) {
if (!previewFillBots) return;
stackPreviewHudLog.push(String(line || '').slice(0, 96));
while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
}
function stackPreviewLogStackDrop(hit, actor) {
if (!previewFillBots || !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 = {};
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 ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
function walkAnimPhaseIndex(now, isWalking) {
const t = isWalking ? (typeof now === 'number' ? now : Date.now()) : 0;
return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % 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', '#ef62b9'];
const PLAY_TINT_BODY = ['#fb4941', '#feaa11', '#fefe6d', '#adfd85', '#45fbfd', '#799afe', '#f87dff', '#fec1fe'];
const playLayerMode = {};
/** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
const playLayerAllDirsQueued = {};
/** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก <img> อย่างเดียว) */
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);
if (Array.isArray(list)) {
list.forEach((c) => {
if (!c || !c.id) return;
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(() => {
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],
};
}
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;
}
/** ลำดับลอง 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_<dir>_idle_<n>_layer_<name>.png หรือ id_<dir>_idle_layer_<name>.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);
}
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
add(base + '_layer_' + layerName + '.png');
return out;
}
function shadowUrlCandidates(id, dir, frameIndex) {
const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
const def = BASE + '/img/default-shadow-' + dir + '.png';
if (c.indexOf(def) === -1) c.push(def);
return c;
}
function shadowUrlCandidatesIdle(id, dir, frameIndex) {
const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
const def = BASE + '/img/default-shadow-' + dir + '.png';
if (c.indexOf(def) === -1) c.push(def);
return c;
}
/** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
function resolvePlayLayerImage(urls) {
for (let i = 0; i < urls.length; i++) {
const img = ensurePlayLayerImage(urls[i]);
if (!img.complete) return { status: 'pending' };
if (img.naturalWidth > 0) return { status: 'ok', img };
}
return { status: 'missing' };
}
function ensurePlayLayerProbesAllDirections(characterId) {
if (!characterId || playLayerAllDirsQueued[characterId]) return;
playLayerAllDirsQueued[characterId] = true;
['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
}
function playCharAnyDirectionLayered(characterId) {
if (!characterId) return false;
return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
}
/** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
function playCharLayerDiscoveryPending(characterId) {
if (!characterId) return true;
const api = playCharLayerFromApi(characterId);
if (api === true || api === false) return false;
if (playCharAnyDirectionLayered(characterId)) return false;
return ['up', 'down', 'left', 'right'].some((d) => {
const m = playLayerMode[characterId + '|' + d];
return m === undefined || m === 'pending';
});
}
function ensurePlayLayerProbe(characterId, dir) {
const key = characterId + '|' + dir;
if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
if (playLayerMode[key] === 'pending') return;
playLayerMode[key] = 'pending';
const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
let ci = 0;
function tryNextProbe() {
if (playLayerMode[key] !== 'pending') return;
if (ci >= cands.length) {
playLayerMode[key] = 'none';
return;
}
const url = cands[ci++];
const img = ensurePlayLayerImage(url);
const fin = () => {
if (playLayerMode[key] !== 'pending') return;
if (img.naturalWidth > 0) {
playLayerMode[key] = 'layered';
return;
}
tryNextProbe();
};
img.onload = fin;
img.onerror = fin;
if (img.complete) fin();
}
tryNextProbe();
}
function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
if (!id) return 0;
const key = id + '_' + dir;
const anim = characterAnimations[key];
if (!anim) return 0;
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
return fi >= 0 ? fi : 0;
}
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
const useIdle = opts && opts.idle;
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const cctx = c.getContext('2d');
let anyTintColorLayer = false;
let skippedShadowWhilePending = false;
const diskLayers = playCharDiskLayersSet(id);
/* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */
const idleLayerStripes = !!useIdle;
const resDir = dir;
const resFi = frameIndex;
for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
const layerName = PLAY_LAYER_ORDER[li];
if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
/* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */
if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') {
continue;
}
let urls;
if (layerName === 'shadow') {
urls = idleLayerStripes
? shadowUrlCandidatesIdle(id, dir, frameIndex)
: shadowUrlCandidates(id, resDir, resFi);
} else {
urls = idleLayerStripes
? layerUrlCandidatesIdle(id, dir, layerName, frameIndex)
: layerUrlCandidates(id, resDir, layerName, resFi);
}
const r = resolvePlayLayerImage(urls);
/* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
if (r.status === 'pending' && layerName === 'shadow') {
skippedShadowWhilePending = true;
continue;
}
if (r.status === 'pending') return null;
if (r.status === 'missing') continue;
const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
}
/* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
if (!anyTintColorLayer) return null;
return { canvas: c, skipCache: skippedShadowWhilePending };
}
function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
if (!tint || !characterId || !rawImg) return rawImg;
ensurePlayCharLayerListFetch();
ensurePlayLayerProbesAllDirections(characterId);
const dirn = dir || 'down';
const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
const apiFlag = playCharLayerFromApi(characterId);
/* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
const charHasLayerFiles = apiFlag === true
|| (apiFlag == null && playCharAnyDirectionLayered(characterId));
/* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */
if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) {
return rawImg;
}
const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
if (charHasLayerFiles) {
if (!isWalking) {
const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase;
const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
const hitI = playLayerCompositeCache.get(cacheKeyIdle);
if (hitI) return hitI;
const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true });
if (packI && packI.canvas) {
if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
return packI.canvas;
}
}
const hit = playLayerCompositeCache.get(cacheKeyWalk);
if (hit) return hit;
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
if (pack && pack.canvas) {
if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
return pack.canvas;
}
return rawImg;
}
/*
* ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม)
*/
return rawImg;
}
function getCharacterImg(id, direction) {
if (!id) return null;
const key = id + '_' + (direction || 'down');
if (characterImages[key]) return characterImages[key];
const img = new Image();
img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
characterImages[key] = img;
return img;
}
function getCharacterFrame(id, direction, now, isWalking) {
if (!id) return null;
const dir = direction || 'down';
const t = typeof now === 'number' ? now : Date.now();
if (!isWalking) {
const idleAnim = ensureCharacterIdleAnim(id, dir);
let idlePhase = idleAnimPhaseIndex(t);
if (isUploadedCharAssetId(id)) idlePhase = 0;
const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
if (idleFi >= 0) return idleAnim.frames[idleFi];
const idfb = idleAnim.fallback;
if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
}
const key = id + '_' + dir;
let anim = characterAnimations[key];
if (!anim) {
anim = { frames: [], fallback: null };
characterAnimations[key] = anim;
for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
const img = new Image();
img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
anim.frames.push(img);
}
anim.fallback = getCharacterImg(id, dir);
}
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
if (fi >= 0) return anim.frames[fi];
const fb = anim.fallback;
if (fb && fb.complete && fb.naturalWidth) return fb;
return null;
}
function getAvatarImg(characterId, direction, now, isWalking) {
const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
if (img) return img;
return defaultAvatarImg;
}
function getStoredCharacterId() {
try {
let v = localStorage.getItem('gameCharacterId');
if (v === 'Chatest') v = ''; // legacy placeholder — ไม่มี sprite จริง
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();
}
const MOVE_SPEED = 0.15;
const PATH_ARRIVE_THRESH = 0.15;
function isMovementKey(code) {
return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
}
function isChatFocused() {
return false;
}
let zoom = 1.4;
let froggerScore = 0;
let lastFroggerKey = 0;
let gauntletObstacles = [];
/** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
let gauntletObsRenderPrev = [];
let gauntletObsRenderNext = [];
let gauntletObsBlendT0 = 0;
let meGauntletJumpTicks = 0;
/** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
let meGauntletJumpVis = 0;
/** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
let gauntletRuntimeTickMs = 220;
let gauntletRuntimeJumpTicks = 16;
/** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
let playStackSwingHz = 0.55;
/** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
let playStackBlockWidthTiles = null;
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
let playJumpSurviveJumpHeightMult = 1.5;
/** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */
let playJumpSurviveMissionTimeSec = 0;
/** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */
let playSpaceShooterMissionTimeSec = 0;
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
let gauntletRuntimeTimeLimitSec = 0;
/** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
let gauntletEndsAtMs = null;
/** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */
let gauntletCrownRunHeldRemote = false;
/** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */
let gauntletCrownPregamePhase = null;
let gauntletCrownLobbyReadyMap = {};
let gauntletCrownCountdownTimer = null;
let lastGauntletJumpKey = 0;
/** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
let gauntletLaneImageUrls = [];
let gauntletLaserTopUrl = '';
let gauntletLaserBottomUrl = '';
let gauntletLaserLineUrl = '';
let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
let gauntletLaserLineWidthPx = 2;
const gauntletAssetImageCache = new Map();
/** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
function normalizeGauntletAssetUrlForPlay(u) {
if (typeof u !== 'string') return '';
const t = u.trim();
if (!t) return '';
if (/^https?:\/\//i.test(t)) return t;
const qIdx = t.indexOf('?');
const base = (qIdx >= 0 ? t.slice(0, qIdx) : t).trim();
const qs = qIdx >= 0 ? t.slice(qIdx) : '';
if (!base) return '';
if (base.startsWith('/')) return base + qs;
if (/^Game\//i.test(base)) return '/' + base.replace(/^\/+/, '') + qs;
return 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;
gauntletAssetImageCache.set(u, rec);
return rec;
}
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;
/** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
let previewQuizPool = [];
let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
let previewQuizStep = 'read';
let previewQuizCurrent = null;
let playLiveQuizScores = {};
/** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
/** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
let quizCarryPool = [];
let quizCarryCurrent = null;
/** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
let quizCarryEmbedPendingQuestion = null;
let quizCarryEmbedCountdownStartAt = 0;
let quizCarryEmbedCountdownEndAt = 0;
/** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
let quizCarryOptionRevealAt = 0;
let quizCarryAnswerCloseAt = 0;
let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
/** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */
let quizCarrySessionLength = 0;
let quizCarryRoundsCompleted = 0;
let quizCarrySessionEnded = false;
/** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */
let quizCarryResultEndTimer = null;
/** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */
let embedPreviewLobbyReturnTimer = null;
/** 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 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) ไม่ลากทั้งฉาก */
let jumpSurviveCamCenterX = 0;
let jumpSurviveCamCenterY = 0;
let jumpSurviveEliminated = false;
/** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
let jumpSurviveSessionStartMs = 0;
let jumpSurvivePlatformScrollPx = 0;
let jumpSurviveVy = 0;
let jumpSurviveLastTickMs = 0;
let jumpSurviveJumpQueued = false;
let jumpSurviveOnGround = false;
let jumpSurviveLastEmitT = 0;
/** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */
let jumpSurviveMissionPhase = null;
let jumpSurviveGameEnded = false;
let jumperMissionCountdownTimer = null;
/** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
let spaceShooterBullets = [];
let spaceShooterAsteroids = [];
let spaceShooterPopups = [];
let spaceShooterLastTickMs = 0;
let spaceShooterSpawnAccMs = 0;
let spaceShooterFireCd = 0;
let spaceShooterSessionStartMs = 0;
let spaceShooterLastMoveEmit = 0;
let spaceShooterGameEnded = false;
/** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
let balloonBossPendingShots = [];
let balloonBossPlayerBullets = [];
let balloonBossBossBullets = [];
let balloonBossSessionStartMs = 0;
let balloonBossLastTickMs = 0;
let balloonBossLastMoveEmit = 0;
let balloonBossPlayerFireCd = 0;
let balloonBossGameEnded = false;
let balloonBossHitFx = [];
let balloonBossBossFireAcc = 0;
/** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
let quizBattleMcqPool = [];
let quizBattleAnsweredComps = new Set();
let quizBattleModalCompId = null;
const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
document.getElementById('room-id').textContent = spaceId;
function hidePlayQuizHud() {
const sb = document.getElementById('play-quiz-scoreboard');
if (sb) sb.classList.add('is-hidden');
const fb = document.getElementById('play-quiz-feedback');
if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
}
function renderPlayQuizScoreboard(scores) {
const wrap = document.getElementById('play-quiz-scoreboard');
const ul = document.getElementById('play-quiz-scoreboard-list');
if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return;
if (!scores || typeof scores !== 'object') return;
wrap.classList.remove('is-hidden');
ul.textContent = '';
const merged = { ...scores };
others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
if (myId != null && merged[myId] == null) merged[myId] = 0;
const rows = [];
if (myId != null) {
rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
}
others.forEach((o, id) => {
rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
});
rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
rows.forEach((row) => {
const li = document.createElement('li');
if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
const spN = document.createElement('span');
spN.className = 'play-quiz-scoreboard-name';
spN.textContent = row.nick;
const spV = document.createElement('span');
spV.className = 'play-quiz-scoreboard-val';
spV.textContent = String(row.sc);
li.appendChild(spN);
li.appendChild(spV);
ul.appendChild(li);
});
if (isQuizCarry() && useCyberPlayHud()) {
wrap.classList.add('is-hidden');
}
}
function initPlayLiveQuizScoresZeros() {
if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return;
playLiveQuizScores = {};
playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
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 = previewFillBots && (botRight + botWrong > 0)
? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
: '';
if (mine.right) {
el.className = 'play-quiz-feedback play-quiz-feedback-ok';
el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (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 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.style.removeProperty('--qmap-bg');
panel.style.removeProperty('--qmap-border');
panel.style.removeProperty('--qmap-border-w');
panel.style.removeProperty('--qmap-shadow');
panel.style.removeProperty('background');
panel.style.removeProperty('border');
panel.style.removeProperty('border-color');
panel.style.removeProperty('border-width');
panel.style.removeProperty('border-style');
panel.style.removeProperty('box-shadow');
textEl.style.removeProperty('--qmap-text');
textEl.style.removeProperty('--qmap-text-shadow');
textEl.style.removeProperty('color');
textEl.style.removeProperty('text-shadow');
textEl.style.removeProperty('font-size');
textEl.style.removeProperty('line-height');
textEl.style.removeProperty('width');
textEl.style.removeProperty('box-sizing');
textEl.style.removeProperty('max-height');
textEl.style.removeProperty('overflow');
}
function clearQuizMapQuestionCarryFeedback() {
if (typeof window.__quizMapCarryFeedbackT === 'number') {
clearTimeout(window.__quizMapCarryFeedbackT);
window.__quizMapCarryFeedbackT = null;
}
const icon = document.getElementById('quiz-map-q-feedback-icon');
const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
const scoreEl = document.getElementById('quiz-map-q-feedback-score');
if (icon) {
icon.classList.add('is-hidden');
icon.removeAttribute('src');
}
if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
if (scoreWrap) {
scoreWrap.classList.add('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'true');
}
}
/** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */
function showQuizMapQuestionCarryFeedback(isCorrect) {
if (!isQuizCarry()) return;
const panel = document.getElementById('quiz-map-question-panel');
const icon = document.getElementById('quiz-map-q-feedback-icon');
const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
const scoreEl = document.getElementById('quiz-map-q-feedback-score');
if (!panel || !icon || panel.classList.contains('is-hidden')) return;
clearQuizMapQuestionCarryFeedback();
icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
icon.classList.remove('is-hidden');
if (isCorrect && scoreWrap && scoreEl) {
scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus;
scoreWrap.classList.remove('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'false');
scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
void scoreEl.offsetWidth;
scoreEl.classList.add('quiz-map-q-feedback-score--pop');
} else if (scoreWrap) {
scoreWrap.classList.add('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'true');
}
window.__quizMapCarryFeedbackT = setTimeout(() => {
window.__quizMapCarryFeedbackT = null;
clearQuizMapQuestionCarryFeedback();
}, QUIZ_MAP_CARRY_FEEDBACK_MS);
}
/** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */
function defaultCarryMapPanelThemePlay() {
return {
panelBg: 'rgba(12, 14, 28, 0.88)',
panelBorder: 'rgba(255, 214, 102, 0.7)',
borderWidthPx: 2,
textColor: '#f1f5f9',
questionFontMinPx: 10,
questionFontMaxPx: 24,
};
}
/** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */
function quizCarryPlaqueMapScaleClampedPlay() {
const sc = Number(quizCarryPlaqueMapScale);
if (!Number.isFinite(sc)) return 1.25;
return Math.max(0.85, Math.min(2.5, sc));
}
/**
* ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps))
* แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max)
*/
function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) {
const ts = tileSize * zoomVal;
const ps = quizCarryPlaqueMapScaleClampedPlay();
const base = Math.max(10, Math.min(24, ts * 0.24 * ps));
const fb = defaultCarryMapPanelThemePlay();
let mn = Number(th && th.questionFontMinPx);
let mx = Number(th && th.questionFontMaxPx);
if (!Number.isFinite(mn)) mn = fb.questionFontMinPx;
if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx;
mn = Math.round(Math.max(8, Math.min(40, mn)));
mx = Math.round(Math.max(10, Math.min(72, mx)));
if (mx < mn) {
const s = mn;
mn = mx;
mx = s;
}
return Math.max(mn, Math.min(mx, base));
}
/**
* ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar)
* zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด
* แผงใช้ justify-content:flex-start ใน CSS — ไม่จัดกึ่งกลางแนวตั้งแล้วโดน overflow ตัดบน-ล่าง
*/
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;
const availH = Math.max(12, panel.clientHeight - padT - padB);
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 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(8, 10, 20, 0.42)',
innerBg: 'rgba(12, 14, 28, 0.82)',
innerBorder: 'rgba(122, 162, 247, 0.45)',
innerBorderWpx: 1,
innerRadiusPx: 12,
digitColor: '#ffe066',
mapDigitCqmin: 78,
mapDigitCqh: 82,
mapDigitMaxPx: 200,
screenDigitVw: 28,
screenDigitMaxPx: 132,
};
}
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) {
numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)';
}
}
/** 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;
}
function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) {
if (!panel || !textEl) return;
if (!isQuizCarry()) {
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
const th = getEffectiveCarryMapPanelThemeForApply();
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 syncPlayQuizMapPanel() {
const panel = document.getElementById('quiz-map-question-panel');
const textEl = document.getElementById('quiz-map-question-text');
if (!panel || !textEl || !mapData) return;
/* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะโหมด quiz (ทับกลาง) — quiz_carry ต้องโชว์ตามโซนที่วาดบนแผนที่ */
if (previewMode && editorEmbedReturn && !isQuizCarry()) {
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 camX = me.x * tileSize;
const camY = me.y * tileSize;
const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2;
const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom;
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');
applyQuizCarryMapPanelThemeIfNeeded(panel, textEl);
const thFont = getEffectiveCarryMapPanelThemeForApply();
const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zoom);
const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10));
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 camX = me.x * tileSize;
const camY = me.y * tileSize;
const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2;
const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom;
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 (!previewMode || !editorEmbedReturn || !mapData || !isQuizCarry()) {
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 camX = me.x * tileSize;
const camY = me.y * tileSize;
const l = (bounds.minX * tileSize - camX) * zoom + canvasEl.width / 2;
const t = (bounds.minY * tileSize - camY) * zoom + canvasEl.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom;
const r = canvasEl.getBoundingClientRect();
const sl = r.left + l;
const st = r.top + t;
ov.classList.add('quiz-carry-embed-countdown--map-anchor');
inner.classList.add('quiz-carry-embed-countdown-inner--map-rect');
inner.style.position = 'fixed';
inner.style.left = Math.round(sl) + 'px';
inner.style.top = Math.round(st) + 'px';
inner.style.width = Math.round(Math.max(100, wPx)) + 'px';
inner.style.height = Math.round(Math.max(72, hPx)) + 'px';
inner.style.transform = 'none';
inner.style.boxSizing = 'border-box';
inner.style.display = 'flex';
inner.style.flexDirection = 'column';
inner.style.alignItems = 'center';
inner.style.justifyContent = 'center';
inner.style.margin = '0';
inner.style.maxWidth = 'none';
}
/** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
function syncQuizCarryEmbedQuestionStrip() {
const wrap = document.getElementById('quiz-carry-embed-q-strip');
const p = document.getElementById('quiz-carry-embed-q-strip-text');
if (!wrap || !p) return;
wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
wrap.classList.add('is-hidden');
wrap.setAttribute('aria-hidden', 'true');
}
function updatePlayQuizTimerDisplay() {
const el = document.getElementById('quiz-game-timer');
if (!el) return;
if (!playQuizPhaseEndsAt) {
el.textContent = '';
return;
}
const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
el.textContent = s + ' วินาที';
}
function clampPreviewMs(n, def, minV, maxV) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(minV, Math.min(maxV, Math.floor(v)));
}
function clampCarrySessionLen(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(500, Math.floor(v)));
}
function buildQuizPoolFromMap(md) {
if (!md || !Array.isArray(md.quizQuestions)) return [];
return md.quizQuestions
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
}
function pickRandomQuizFromPool(pool) {
if (!pool || !pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function clearPreviewBotAnswerPaths() {
if (!previewFillBots) return;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
});
}
function resetPreviewBotsQuizState() {
if (!previewFillBots) 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;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · สุ่มจากชุด ' + 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);
}
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;
}
/** เหมือน 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 && spawnTileWalkablePlay(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;
}
/** สอดคล้องกับ server pickSpawnForJoin — ใช้พรีวิวบอท / ทดสอบ */
function pickSpawnForJoinPlay(md, joinOrderIndex) {
if (!md) return { x: 1, y: 1 };
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 (spawnTileWalkablePlay(md, x, y)) return { x, y };
return pickRandomSpawnFromMapPlay(md);
}
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
const pick = slots[j];
if (pick && spawnTileWalkablePlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
return pickRandomSpawnFromMapPlay(md);
}
return pickRandomSpawnFromMapPlay(md);
}
const GAUNTLET_PREVIEW_MAX = 6;
function gauntletSpawnYsPlay(md, playerCount) {
const h = md.height || 15;
const lo = 1;
const hi = Math.max(lo, h - 2);
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
if (hi <= lo) return Array.from({ length: n }, () => lo);
const ys = [];
for (let i = 0; i < n; i++) {
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
}
return ys;
}
function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return [];
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
}
}
slots.sort((a, b) => a.y - b.y || a.x - b.x);
return slots;
}
function collectGauntletSpawnSlotsPlay(md) {
const explicit = md.gauntletPlayerSpawns;
if (Array.isArray(explicit) && explicit.length > 0) {
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (const raw of explicit) {
if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
const x = Math.floor(Number(raw && raw.x));
const y = Math.floor(Number(raw && raw.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
if (!spawnTileWalkablePlay(md, x, y)) continue;
slots.push({ x, y });
}
if (slots.length > 0) return slots;
}
return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
}
function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
const w = md.width || 20;
if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
for (let d = 0; d < w; d++) {
if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
}
return Math.max(0, Math.min(w - 1, spawnColX));
}
function gauntletSpawnPositionsPlay(md, playerCount) {
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
const slots = collectGauntletSpawnSlotsPlay(md);
const fallbackYs = gauntletSpawnYsPlay(md, n);
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
const out = [];
for (let i = 0; i < n; i++) {
const y = i < slots.length
? slots[i].y
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
const slotX = i < slots.length ? slots[i].x : null;
const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
out.push({ x, y });
}
return out;
}
/**
* โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
* @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
*/
function applyGauntletPreviewSpawnLayout(onlyBots) {
if (!mapData || mapData.gameType !== 'gauntlet') return;
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
const humanCount = 1 + realIds.length;
const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
const pos = gauntletSpawnPositionsPlay(mapData, total);
if (onlyBots) {
let idx = humanCount;
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
return;
}
let idx = 0;
if (idx < pos.length) {
me.x = pos[idx].x;
me.y = pos[idx].y;
me.tx = me.x;
me.ty = me.y;
idx++;
}
realIds.forEach((rid) => {
const o = others.get(rid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
}
function isPreviewBotId(id) {
return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
}
function countPlayHumans() {
let n = 1;
others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
return n;
}
function rebalancePreviewBots() {
if (!playBotsEnabled() || !mapData) return;
if (isDetectiveMinigamePlay() && isQuizCarry()) {
/* บอทคดีสืบสวน — ใช้ stepQuizCarryPreviewBots */
}
const human = countPlayHumans();
const wantBots = Math.max(0, playBotTargetHeadcount() - human);
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o) return;
if (o.botTier == null) {
const tierRoll = Math.random();
o.botTier = 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) {
const id = PREVIEW_BOT_PREFIX + (++previewBotSeq);
const joinIdx = countPlayHumans() + botIds.length;
const sp = pickSpawnForJoinPlay(mapData, joinIdx);
const jx = (Math.random() - 0.5) * 0.4;
const jy = (Math.random() - 0.5) * 0.4;
let x = sp.x + 0.5 + jx;
let y = sp.y + 0.5 + jy;
if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(x, y);
x = pathSnap.x;
y = pathSnap.y;
}
const tierRoll = Math.random();
const botTier = 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: getPlayCharacterId(),
playTint: playTintFromPeerId(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,
balloonBossBalloons: 5,
balloonBossEliminated: false,
botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
});
botIds.push(id);
}
let k = 0;
[...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
const o = others.get(bid);
if (!o) return;
const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
o.nickname = 'บอท ' + (++k) + ' ' + tag;
});
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();
}
if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
}
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 (!previewFillBots || !mapData || !isQuiz() || !previewQuizCurrent) return;
const correctTrue = !!previewQuizCurrent.answerTrue;
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
const tier = o.botTier || 'avg';
let pCorrect = 0.52;
if (tier === 'sharp') pCorrect = 0.84;
else if (tier === 'weak') pCorrect = 0.22;
if (Math.random() < 0.14) {
o.botAnswerWander = true;
return;
}
const wantsCorrect = Math.random() < pCorrect;
const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
const zoneGrid = targetTrue ? qt : qf;
const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
if (!dest) {
o.botAnswerWander = true;
return;
}
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
if (!path || path.length <= 1) {
o.botAnswerWander = true;
return;
}
o.botPath = path.slice(1);
});
}
/** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
const walkable = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return row && row[x] !== 1;
};
const fx = Math.floor(Number(sx));
const fy = Math.floor(Number(sy));
if (!walkable(fx, fy)) {
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
const q = [[fx, fy]];
const seen = new Set([`${fx},${fy}`]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = `${nx},${ny}`;
if (seen.has(k)) continue;
if (!walkable(nx, ny)) continue;
seen.add(k);
if (!inAnswer(nx, ny)) {
return { x: nx + 0.5, y: ny + 0.5 };
}
q.push([nx, ny]);
}
}
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
const tx = Math.floor(ox);
const ty = Math.floor(oy);
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
const inT = quizCellOnPlay(qt, tx, ty);
const inF = quizCellOnPlay(qf, tx, ty);
let choice = null;
if (inT && !inF) choice = true;
else if (inF && !inT) choice = false;
else if (inT && inF) choice = true;
const right = choice !== null && choice === correctTrue;
return { choice, right };
}
/** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง 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) + 1;
} else {
playQuizPlayerLocal.cannotTrue = true;
playQuizPlayerLocal.cannotFalse = true;
const sp = pickRandomSpawnFromMapPlay(mapData);
const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
me.x = pos.x;
me.y = pos.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 (previewFillBots) {
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) + 1;
} else {
o.quizCannotTrue = true;
o.quizCannotFalse = true;
const sp = pickRandomSpawnFromMapPlay(mapData);
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;
}
results.push({
id,
right: br.right,
choice: br.choice,
score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
});
});
}
const r = { results, scores: { ...playLiveQuizScores } };
renderPlayQuizScoreboard(playLiveQuizScores);
showPlayQuizFeedback(r);
}
function advancePreviewQuizIfDue() {
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') {
const q = pickRandomQuizFromPool(pool);
if (q) applyPreviewReadPhase(q, pool.length);
}
}
async function loadPreviewQuizAndStart() {
resetPreviewBotsQuizState();
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);
previewQuizPool = pool;
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;
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 q = pickRandomQuizFromPool(pool);
if (q) applyPreviewReadPhase(q, pool.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;
previewQuizPool = [];
previewQuizCurrent = null;
previewQuizStep = 'read';
playLiveQuizScores = {};
playPath = [];
hidePlayQuizHud();
quizCarryJoinSettingsSnap = null;
quizCarryMapPanelTheme = null;
quizCarryChoicePlaqueThemes = null;
resetQuizCarryPlayState();
resetQuizBattlePlayState();
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';
}
}
function setupPlayQuizUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) {
if (previewMode && editorEmbedReturn) 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 = '';
loadPreviewQuizAndStart();
} else {
playQuizPhaseLocal = null;
playQuizText = 'รอคำถามจากโฮสต์…';
playQuizPhaseEndsAt = 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 = '';
}
}
function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
function isGauntletFaceRightMapMno9kb07() {
return !!(isGauntlet() && playSessionMapId === GAUNTLET_FACE_RIGHT_MAP_ID);
}
/** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */
function isGauntletCrownHeistMapPlay() {
return isGauntletFaceRightMapMno9kb07();
}
let gauntletCrownHowtoVisible = false;
function isGauntletCrownPregameBlockingPlay() {
if (!isGauntletCrownHeistMapPlay()) return false;
if (gauntletCrownRunHeldRemote) return true;
if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
return false;
}
function isStack() { return mapData && mapData.gameType === 'stack'; }
function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
function currentPlayMapId() {
const q = (playSessionMapId || '').trim();
if (q) return q;
if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim();
return (playMapIdFromQuery || '').trim();
}
function isJumpSurviveMissionUiMapPlay() {
return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID;
}
function isJumpSurviveMissionPregameBlockingPlay() {
if (!isJumpSurviveMissionUiMapPlay()) return false;
return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended';
}
function jumperAssetUrl(file) {
return BASE + '/img/Jumper/' + file;
}
function hideConflictingOverlaysForJumpSurviveMission() {
hideConflictingOverlaysForGauntletCrown();
}
function jumpSurviveTimeLimitSecForMission() {
const m = Number(mapData && mapData.jumpSurviveTimeSec);
if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
const j = Number(playJumpSurviveMissionTimeSec);
if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j);
return 60;
}
function jumpSurviveRemainingSecMission() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null;
const lim = jumpSurviveTimeLimitSecForMission();
if (!(lim > 0)) return null;
const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function applyJumpSurviveJumperPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = jumperAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = jumperAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
}
function showJumpSurviveMissionHowtoOverlay() {
if (!isJumpSurviveMissionUiMapPlay()) return;
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
jumpSurviveMissionPhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.textContent = '';
st.classList.remove('gch-status--jumper');
st.classList.add('is-hidden');
}
const btn = document.getElementById('btn-gch-ready');
if (btn) {
const humans = quizCarryPregameHumanIds();
const soleHuman = humans.length === 1;
const canGo = soleHuman || 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');
}
}
function beginJumpSurviveMissionCountdownThenRun() {
if (!isJumpSurviveMissionUiMapPlay()) return;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
jumpSurviveMissionPhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.classList.add('is-hidden');
st.classList.remove('gch-status--jumper');
}
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = function () {
if (cd) cd.classList.add('is-hidden');
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
jumpSurviveMissionPhase = 'live';
jumpSurviveGameEnded = false;
jumpSurviveEliminated = false;
jumpSurviveInitRuntime();
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
numEl.textContent = String(n);
const step = function () {
n--;
if (n > 0) {
numEl.textContent = String(n);
jumperMissionCountdownTimer = setTimeout(step, 1000);
} else {
jumperMissionCountdownTimer = null;
runFinish();
}
};
jumperMissionCountdownTimer = setTimeout(step, 1000);
}
/** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */
function jumpSurviveGradeFromSurvivorCount(survivors) {
const s = Math.max(0, Math.floor(Number(survivors) || 0));
if (s >= 6) return 'A';
if (s === 5) return 'B';
if (s === 4) return 'C';
if (s === 3) return 'D';
if (s === 2) return 'E';
return 'F';
}
function jumpSurviveRollRewardCardForGrade(grade) {
if (grade === 'F') return null;
const r = Math.random();
if (grade === 'A' || grade === 'B') {
if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'C' || grade === 'D') {
if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'E') {
if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
function jumpSurviveMissionTotalParticipants() {
let n = myId != null ? 1 : 0;
if (others && typeof others.size === 'number') n += others.size;
return n;
}
function jumpSurviveMissionCountAlive() {
let n = 0;
if (myId != null && !jumpSurviveEliminated) n++;
if (others && typeof others.forEach === 'function') {
others.forEach(function (o) {
if (o && !o.jumpSurviveEliminated) n++;
});
}
return n;
}
/** จบทันทีเมื่อไม่มีผู้รอด หรือเหลือคนเดียว (หลายคนในรอบ) — โหมด solo 1 คนยังเล่นจนหมดเวลา */
function jumpSurviveMissionMaybeEarlyFinish() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
const total = jumpSurviveMissionTotalParticipants();
const alive = jumpSurviveMissionCountAlive();
if (alive === 0) {
endJumpSurviveMissionRound();
return;
}
if (total >= 2 && alive === 1) {
endJumpSurviveMissionRound();
}
}
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 = rows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: row.baseScore,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const 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' || !previewFillBots || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
rankBonus: 0,
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: o.jumpSurviveEliminated ? 0 : 100,
eliminated: !!o.jumpSurviveEliminated,
rankBonus: 0,
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
let survivorCount = 0;
for (let si = 0; si < baseRows.length; si++) {
if (baseRows[si] && !baseRows[si].eliminated) survivorCount++;
}
const participantCount = baseRows.length;
const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: jumpSurviveRollRewardCardForGrade(grade),
totalParts: totalParts,
uiSkin: 'jumper',
survivorCount: survivorCount,
participantCount: participantCount,
};
}
function endJumpSurviveMissionRound() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return;
jumpSurviveGameEnded = true;
jumpSurviveMissionPhase = 'ended';
applyJumpSurviveJumperPanelImages();
const mission = jumpSurviveBuildMissionPayload();
showGauntletCrownMissionOverlay(mission);
}
function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
function quizCarryNeonColorForChoice(choiceIndex) {
const i = Math.max(0, choiceIndex | 0);
const h = (i * 47 + 100) % 360;
return 'hsl(' + h + ', 72%, 62%)';
}
function quizCarryMinimapOptionFillCss(ov) {
const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1);
if (th.borderMode === 'fixed' && th.fixedBorder) {
const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim());
if (m) {
const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1;
const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32;
const t = Math.round(aa * 1000) / 1000;
return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')';
}
}
const i = Math.max(0, ov - 1);
const h = (i * 47 + 100) % 360;
return 'hsla(' + h + ', 58%, 52%, 0.32)';
}
function normalizeQuizCarryQuestionFromAny(q) {
if (!q) return null;
let text = String(q.text || q.question || q.prompt || q.title || '').trim();
if (Array.isArray(q.choices) && q.choices.length >= 2) {
const choices = q.choices.map((c) => String(c));
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
let chSlot = Number(q.countdownHighlightSlot);
if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null;
else chSlot = Math.floor(chSlot);
let choiceImageUrls = null;
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx]));
if (urls.some((u) => u)) choiceImageUrls = urls;
}
return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls };
}
if (!text) return null;
const t = !!q.answerTrue;
return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null };
}
function buildQuizCarryPoolFromMap(md) {
if (!md || !Array.isArray(md.quizQuestions)) return [];
const out = [];
for (let i = 0; i < md.quizQuestions.length; i++) {
const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
if (n) out.push(n);
}
return out;
}
function getQuizCarryHubTileBounds(md) {
if (!md || md.gameType !== 'quiz_carry') return null;
const grid = md.quizCarryHubArea;
if (!grid || !grid.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let yy = 0; yy < grid.length; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (row[xx] === 1) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
}
}
}
if (minX === Infinity) return null;
return { minX, minY, maxX, maxY };
}
/** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
function getQuizCarryOptionClusterBounds(md, slot1toN) {
const g = md && md.quizCarryOptionArea;
if (!g || !g.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let n = 0, sumX = 0, sumY = 0;
for (let yy = 0; yy < g.length; yy++) {
const row = g[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (Number(row[xx]) === slot1toN) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
sumX += xx + 0.5;
sumY += yy + 0.5;
n++;
}
}
}
if (!n || minX === Infinity) return null;
return {
minX, minY, maxX, maxY,
cx: sumX / n,
cy: sumY / n,
};
}
function canvasWordWrapLines(ctx, text, maxWidth) {
const raw = String(text || '').trim();
if (!raw) return [];
const words = raw.split(/\s+/);
const lines = [];
let cur = words[0];
for (let i = 1; i < words.length; i++) {
const w = words[i];
const test = cur + ' ' + w;
if (ctx.measureText(test).width <= maxWidth) cur = test;
else {
lines.push(cur);
cur = w;
}
}
lines.push(cur);
return lines;
}
function quizCarryOptionHeldByAnyone(optionIdx) {
if (optionIdx == null || optionIdx < 0) return false;
if (me.quizCarryHeld === optionIdx) return true;
for (const o of others.values()) {
if (o && o.quizCarryHeld === optionIdx) return true;
}
return false;
}
/** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */
function spriteOverlapsQuizCarryHubArea(md, sp) {
if (!md || md.gameType !== 'quiz_carry' || !sp) return false;
const hub = md.quizCarryHubArea;
if (!hub || !hub.length) return false;
const mw = md.width || 20, mh = md.height || 15;
const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
for (let yy = y0; yy < y0 + hh; yy++) {
if (yy < 0 || yy >= mh) continue;
const row = hub[yy];
if (!row) continue;
for (let xx = x0; xx < x0 + ww; xx++) {
if (xx < 0 || xx >= mw) continue;
if (row[xx] === 1) return true;
}
}
return false;
}
/** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
if (n < 1) return null;
let p = preferredIdx | 0;
if (p < 0 || p >= n) p = 0;
if (!quizCarryOptionHeldByAnyone(p)) return p;
const avail = [];
for (let i = 0; i < n; i++) {
if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
}
if (!avail.length) return null;
return avail[Math.floor(Math.random() * avail.length)];
}
/**
* วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl
* @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
* @param plaqueExtra optional { imageUrl }
*/
function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) {
const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex);
const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex);
const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)';
const textCol = th.textColor || '#f8f9ff';
const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
const elImg = plaqueExtra && plaqueExtra.imageElement;
const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
const simplePreviewPlaque = previewMode && editorEmbedReturn;
function pathBoard() {
ctx.beginPath();
if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
else ctx.rect(signX, signY, w, h);
}
if (!simplePreviewPlaque) {
for (let g = 4; g >= 1; g--) {
ctx.strokeStyle = neon;
ctx.globalAlpha = 0.07 + g * 0.06;
ctx.lineWidth = baseLw + g * 3.2;
pathBoard();
ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.fillStyle = fillCol;
pathBoard();
ctx.fill();
if (drawPlaqueImg) {
ctx.save();
pathBoard();
ctx.clip();
const padImg = Math.max(2, padY * 0.65);
const ix = signX + padImg;
const iy = signY + padImg;
const iw = Math.max(0, w - padImg * 2);
const ih = Math.max(0, h - padImg * 2);
if (iw > 0 && ih > 0) {
const nw = drawPlaqueImg.naturalWidth;
const nh = drawPlaqueImg.naturalHeight;
const ir = nw / nh;
let dw;
let dh;
if (iw / ih > ir) {
dh = ih;
dw = dh * ir;
} else {
dw = iw;
dh = dw / ir;
}
const dx = ix + (iw - dw) / 2;
const dy = iy + (ih - dh) / 2;
ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
}
ctx.restore();
}
ctx.strokeStyle = neon;
if (simplePreviewPlaque) {
ctx.lineWidth = Math.max(1, baseLw * 0.85);
ctx.shadowBlur = 0;
pathBoard();
ctx.stroke();
} else {
ctx.lineWidth = baseLw;
ctx.shadowColor = neon;
ctx.shadowBlur = 14;
pathBoard();
ctx.stroke();
ctx.shadowBlur = 8;
pathBoard();
ctx.stroke();
ctx.shadowBlur = 0;
}
const attachX = signX + w / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = textCol;
const lh = lineH || 12;
if (!simplePreviewPlaque && lines && lines.length) {
ctx.shadowColor = 'rgba(0,0,0,0.55)';
ctx.shadowBlur = 3;
}
if (lines && lines.length) {
const n = lines.length;
const textBlockH = n * lh;
const y0 = signY + Math.max(padY, (h - textBlockH) / 2);
const firstLineCenterY = y0 + lh / 2;
for (let li = 0; li < n; li++) {
ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh);
}
}
ctx.shadowBlur = 0;
if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.moveTo(tether.x0, tether.y0);
ctx.lineTo(tether.x1, tether.y1);
ctx.stroke();
}
}
/**
* กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based
*/
function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null;
const q = quizCarryCurrent;
const choices = q.choices;
if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null;
const i = idx0Based;
const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
if (!b) return null;
const ts = tileSize * zoom;
const ps = quizCarryPlaqueMapScaleClampedPlay();
const choiceText = String(choices[i] || '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i);
const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
: (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null;
const tileSpanX = (b.maxX - b.minX + 1);
const tileSpanY = (b.maxY - b.minY + 1);
const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
ctx.save();
let signX;
let signY;
let w;
let h;
if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
const minW = minPlaqueW;
const minH = minPlaqueH;
signX = sx - minW / 2;
signY = sy - minH / 2;
w = minW;
h = minH;
ctx.restore();
return { signX, signY, w, h };
}
const line = choiceText;
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
let maxW = Math.max(56, tileSpanX * ts - 14);
const lineH = fontPx * 1.2;
let textLines = canvasWordWrapLines(ctx, line, maxW);
if (!textLines.length) textLines = [line];
if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
const fp2 = Math.max(9, fontPx * 0.85);
ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
const lh2 = fp2 * 1.2;
maxW = Math.max(48, maxW - 4);
textLines = canvasWordWrapLines(ctx, line, maxW);
if (textLines.length * lh2 > tileSpanY * ts - 8) {
textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
const padX = 10;
const padY = 8;
w = Math.ceil(maxLineW + padX * 2);
h = Math.ceil(textLines.length * lh2 + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
signX = sx - w / 2;
signY = sy - h / 2;
ctx.restore();
return { signX, signY, w, h };
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
let maxLineW = 0;
for (let ti = 0; ti < textLines.length; ti++) {
const lw = ctx.measureText(textLines[ti]).width;
if (lw > maxLineW) maxLineW = lw;
}
const padX = 10;
const padY = 8;
w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
h = Math.ceil(textLines.length * lineH + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
signX = sx - w / 2;
signY = sy - h / 2;
ctx.restore();
return { signX, signY, w, h };
}
/**
* ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor)
* ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด
*/
function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) {
if (!isQuizCarry() || !mapData) return;
if (mapData.carryEmbedCountdownHighlight === false) return;
const optIdx = getQuizCarryLocalGrabbableOptionIndex();
if (optIdx == null) return;
const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx);
if (!pr || pr.w < 4 || pr.h < 4) return;
const rx = pr.signX;
const ry = pr.signY;
const rw = pr.w;
const rh = pr.h;
const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14));
const colRaw = mapData.carryEmbedCountdownHighlightColor;
const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a';
const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180);
ctx.save();
function pathRr() {
ctx.beginPath();
if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr);
else ctx.rect(rx, ry, rw, rh);
}
pathRr();
ctx.fillStyle = strokeBase;
ctx.globalAlpha = 0.12 * pulse;
ctx.fill();
for (let g = 6; g >= 1; g--) {
ctx.strokeStyle = strokeBase;
ctx.globalAlpha = (0.07 + g * 0.055) * pulse;
ctx.lineWidth = 2.5 + g * 4;
pathRr();
ctx.stroke();
}
ctx.globalAlpha = 0.98;
ctx.strokeStyle = strokeBase;
ctx.lineWidth = 4;
ctx.shadowColor = strokeBase;
ctx.shadowBlur = 22 * pulse;
pathRr();
ctx.stroke();
ctx.shadowBlur = 12 * pulse;
pathRr();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.restore();
}
function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
if (!quizCarryOptionsPickableNow()) return;
const choices = quizCarryCurrent.choices;
if (!choices || !choices.length) return;
const maxSlots = choices.length;
const ts = tileSize * zoom;
const ps = quizCarryPlaqueMapScaleClampedPlay();
ctx.save();
for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
if (quizCarryOptionHeldByAnyone(i)) continue;
const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
if (!b) continue;
const choiceText = String(choices[i] || '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
: (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
const tileSpanX = (b.maxX - b.minX + 1);
const tileSpanY = (b.maxY - b.minY + 1);
const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
const lineH = fontPx * 1.2;
const padY = 8;
const minW = minPlaqueW;
const minH = minPlaqueH;
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
continue;
}
const line = choiceText;
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
let maxW = Math.max(56, tileSpanX * ts - 14);
const lineH = fontPx * 1.2;
let textLines = canvasWordWrapLines(ctx, line, maxW);
if (!textLines.length) textLines = [line];
if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
const fp2 = Math.max(9, fontPx * 0.85);
ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
const lh2 = fp2 * 1.2;
maxW = Math.max(48, maxW - 4);
textLines = canvasWordWrapLines(ctx, line, maxW);
if (textLines.length * lh2 > tileSpanY * ts - 8) {
textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
const padX = 10;
const padY = 8;
let w = Math.ceil(maxLineW + padX * 2);
let h = Math.ceil(textLines.length * lh2 + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
continue;
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
let maxLineW = 0;
for (let ti = 0; ti < textLines.length; ti++) {
const lw = ctx.measureText(textLines[ti]).width;
if (lw > maxLineW) maxLineW = lw;
}
const padX = 10;
const padY = 8;
let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
let h = Math.ceil(textLines.length * lineH + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
}
ctx.restore();
}
function quizCarryHubCellPlay(md, tx, ty) {
const g = md && md.quizCarryHubArea;
return !!(g && g[ty] && g[ty][tx] === 1);
}
function quizCarryInteractiveCellPlay(md, tx, ty) {
const g = md && md.interactive;
return !!(g && g[ty] && g[ty][tx] === 1);
}
function quizCarryMapHasAnswerInteractive(md) {
if (!md || !md.interactive || !md.interactive.length) return false;
const h = md.height || 15, w = md.width || 20;
for (let y = 0; y < h; y++) {
const row = md.interactive[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1) return true;
}
}
return false;
}
/** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
function quizCarryOptionIndexAtPlay(md, tx, ty) {
const g = md && md.quizCarryOptionArea;
if (!g || !g[ty]) return null;
const v = g[ty][tx];
if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
const idx = v - 1;
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
if (n < 1 || idx >= n) return null;
return idx;
}
/** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
function quizCarryFootprintOverlapsHub(px, py) {
if (!mapData || !isQuizCarry()) return false;
const md = mapData;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryHubCellPlay(md, tx, ty)) return true;
}
return false;
}
/** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
function quizCarryFootprintAdjacentToHub(md, px, py) {
if (!md) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryHubCellPlay(md, tx, ty)) continue;
for (let di = 0; di < dirs.length; di++) {
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
if (quizCarryHubCellPlay(md, nx, ny)) return true;
}
}
return false;
}
function quizCarryCanSubmitAtHub(md, px, py) {
if (!md) return false;
return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
}
function quizCarryFootprintOverlapsInteractive(md, px, py) {
if (!md) return false;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
}
return false;
}
function quizCarryFootprintAdjacentToInteractive(md, px, py) {
if (!md) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
for (let di = 0; di < dirs.length; di++) {
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
}
}
return false;
}
function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
if (!md) return false;
return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
}
/** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
function quizCarryCanSubmitAnswerAt(md, px, py) {
if (!md) return false;
if (quizCarryMapHasAnswerInteractive(md)) {
return quizCarryCanSubmitAtInteractiveZone(md, px, py);
}
return quizCarryCanSubmitAtHub(md, px, py);
}
/** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
function formatQuizCarryQuestionHud(q) {
if (!q) return '';
return String(q.text || q.question || q.prompt || q.title || '').trim();
}
function quizCarryRestoreEmbedPhaseLabel() {
const phaseEl = document.getElementById('quiz-game-phase-label');
if (!phaseEl || !mapData) return;
phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
: 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
}
function isQuizCarryEmbedCountdownBlockingMovement() {
return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryEmbedCountdownEndAt > Date.now());
}
function isQuizCarryEmbedLobbyBlockingMovement() {
return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryPregameActive);
}
function quizCarryPregameHumanIds() {
const ids = [];
if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId));
others.forEach((_, id) => {
if (!isPreviewBotId(id)) ids.push(String(id));
});
return ids;
}
function quizCarryPregameBotCount() {
let n = 0;
others.forEach((_, id) => {
if (isPreviewBotId(id)) n++;
});
return n;
}
function quizCarryPregameReadyNumerator() {
let n = quizCarryPregameBotCount();
quizCarryPregameHumanIds().forEach((id) => {
if (quizCarryLobbyReadyMap[id]) n++;
});
return n;
}
function quizCarryPregameTotalPlayers() {
return quizCarryPregameHumanIds().length + quizCarryPregameBotCount();
}
function isMePlayHost() {
if (myId == null) return false;
if (playHostId == null) return true;
return String(playHostId) === String(myId);
}
function hideQuizCarryPregameOverlay() {
const ov = document.getElementById('quiz-carry-pregame-overlay');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
}
function showQuizCarryPregameOverlay() {
const ov = document.getElementById('quiz-carry-pregame-overlay');
if (ov) {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
updateQuizCarryPregameHud();
}
function quizCarrySyncGuestReadyIfNeeded() {
if (!quizCarryPregameActive || myId == null || isMePlayHost()) return;
const sid = String(myId);
if (quizCarryLobbyReadyMap[sid]) return;
quizCarryLobbyReadyMap[sid] = true;
if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true });
}
function updateQuizCarryPregameHud() {
if (!quizCarryPregameActive) return;
const st = document.getElementById('quiz-carry-pregame-status');
const primary = document.getElementById('quiz-carry-pregame-primary');
const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
const num = quizCarryPregameReadyNumerator();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
if (st) {
st.textContent = 'Ready Status : ' + num + '/' + tot;
}
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
if (primaryImg) {
primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
primaryImg.alt = humansReady ? 'START' : 'READY';
}
if (primary) {
primary.classList.toggle('is-start-phase', humansReady);
primary.classList.toggle('is-read-only', !isMePlayHost());
primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady);
primary.disabled = !isMePlayHost();
primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false'));
primary.title = isMePlayHost()
? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
}
function beginQuizCarryEmbedPregame() {
if (!(previewMode && editorEmbedReturn && isQuizCarry())) return;
quizCarryPregameActive = true;
quizCarryLobbyReadyMap = {};
quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; });
showQuizCarryPregameOverlay();
if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request');
quizCarrySyncGuestReadyIfNeeded();
updateQuizCarryPregameHud();
}
function endQuizCarryEmbedPregameAndStart() {
if (!quizCarryPregameActive) return;
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryPickNextQuestion();
if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) {
quizCarryRestoreEmbedPhaseLabel();
}
}
function quizCarryOptionsPickableNow() {
if (!isQuizCarry() || !quizCarryCurrent) return true;
if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
return Date.now() >= quizCarryOptionRevealAt;
}
function quizCarryApplyPhaseTimersForCurrentQuestion() {
if (!quizCarryCurrent) {
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
return;
}
const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
const t0 = Date.now();
if (readMs <= 0) {
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = t0 + ansMs;
} else {
quizCarryOptionRevealAt = t0 + readMs;
quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
}
}
function updateQuizCarryCarryPhaseHud() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!isQuizCarry() || !quizCarryCurrent) return;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (!phaseEl) return;
const now = Date.now();
if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
return;
}
if (quizCarryAnswerCloseAt > now) {
const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
return;
}
quizCarryRestoreEmbedPhaseLabel();
}
function tickQuizCarryRoundTimers() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
if (quizCarryEmbedCountdownEndAt > Date.now()) return;
if (quizCarryEmbedPendingQuestion) return;
if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
if (Date.now() < quizCarryAnswerCloseAt) return;
quizCarryAnswerCloseAt = 0;
quizCarryOptionRevealAt = 0;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
renderPlayQuizScoreboard(playLiveQuizScores);
quizCarryAfterRoundResolved();
}
/** นับ 3–2–1: ไม่โชว์ข้อความคำถามใน overlay — มีแค่เลข */
function hideQuizCarryEmbedCountdownQuestionChrome() {
const cq = document.getElementById('quiz-carry-embed-countdown-q');
const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
if (cq) {
cq.textContent = '';
cq.style.display = 'none';
}
if (ck) ck.style.display = 'none';
}
function hideQuizCarryEmbedCountdownOverlay() {
resetQuizCarryEmbedCountdownLayoutDom();
const ov = document.getElementById('quiz-carry-embed-countdown');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
hideQuizCarryEmbedCountdownQuestionChrome();
}
function tickQuizCarryEmbedCountdown() {
if (!previewMode || !editorEmbedReturn || !isQuizCarry()) return;
if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
const now = Date.now();
const ov = document.getElementById('quiz-carry-embed-countdown');
const numEl = document.getElementById('quiz-carry-embed-countdown-num');
if (now >= quizCarryEmbedCountdownEndAt) {
const pq = quizCarryEmbedPendingQuestion;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
hideQuizCarryEmbedCountdownOverlay();
if (!pq) return;
quizCarryCurrent = pq;
preloadQuizCarryChoiceImages(pq);
playQuizText = formatQuizCarryQuestionHud(pq);
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
quizCarryRestoreEmbedPhaseLabel();
others.forEach((o) => {
if (!o) return;
o.botQuizCarryPathfindAfter = 0;
});
syncQuizCarryEmbedQuestionStrip();
quizCarryApplyPhaseTimersForCurrentQuestion();
return;
}
const elapsed = now - quizCarryEmbedCountdownStartAt;
const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
if (numEl) numEl.textContent = String(Math.max(1, Math.min(3, n)));
if (ov) {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
syncQuizCarryEmbedCountdownLayout();
hideQuizCarryEmbedCountdownQuestionChrome();
syncQuizCarryEmbedQuestionStrip();
}
function quizCarryPickNextQuestion() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!quizCarryPool.length) {
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
hideQuizCarryEmbedCountdownOverlay();
playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
return;
}
let q;
let guard = 0;
do {
q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
guard++;
} while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
if (previewMode && editorEmbedReturn) {
quizCarryEmbedPendingQuestion = q;
preloadQuizCarryChoiceImages(q);
quizCarryEmbedCountdownStartAt = Date.now();
quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
quizCarryCurrent = null;
playQuizText = '';
clearQuizMapQuestionCarryFeedback();
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
o.botPathStuckTicks = 0;
/* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
o.botQuizCarryPathfindAfter = 0;
});
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = '…';
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
tickQuizCarryEmbedCountdown();
syncQuizCarryEmbedQuestionStrip();
return;
}
quizCarryCurrent = q;
preloadQuizCarryChoiceImages(q);
playQuizText = formatQuizCarryQuestionHud(q);
clearQuizMapQuestionCarryFeedback();
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
});
quizCarryApplyPhaseTimersForCurrentQuestion();
}
/** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */
function quizCarryAfterRoundResolved() {
if (!isQuizCarry()) return;
if (isDetectiveMinigamePlay()) {
finishDetectiveMinigameAndReturnLobby();
return;
}
if (!previewMode) {
quizCarryPickNextQuestion();
return;
}
quizCarryRoundsCompleted++;
const lim = quizCarrySessionLength;
if (lim > 0 && quizCarryRoundsCompleted >= lim) {
showQuizCarrySessionCompleteOverlay();
return;
}
quizCarryPickNextQuestion();
}
function playQuizCarryAvatarUrlForMission(characterId) {
if (!characterId) return '';
return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
}
/** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */
function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) {
if (!imgEl || !characterId) return;
let attempts = 0;
const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId);
const tick = () => {
attempts++;
const nowT = Date.now();
const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down');
if (!rawImg) {
if (fallbackUrl) imgEl.src = fallbackUrl;
return;
}
if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) {
if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts);
setTimeout(tick, 90);
return;
}
if (!rawImg.naturalWidth) {
if (fallbackUrl) imgEl.src = fallbackUrl;
return;
}
const tint = playTintFromPeerId(peerId != null ? String(peerId) : String(characterId));
const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint);
if (out && out.tagName === 'CANVAS' && out.width > 0) {
try {
imgEl.src = out.toDataURL('image/png');
return;
} catch (e) { /* ต่อไปลอง walk หรือ URL */ }
}
if (out && out.src && out !== rawImg) {
imgEl.src = out.src;
return;
}
if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) {
setTimeout(tick, 120);
return;
}
imgEl.src = rawImg.src || fallbackUrl;
};
tick();
}
/** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80100, B 6079, C 059 */
function quizCarryGradeFromTeamAverage(avgRounded) {
if (avgRounded >= 80) return 'A';
if (avgRounded >= 60) return 'B';
return 'C';
}
function quizCarryRollCardRewardForGrade(grade) {
const u = Math.random() * 100;
if (grade === 'A') {
if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'B') {
if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'C') {
if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
return null;
}
function quizCarryBuildMissionRankList() {
const ranks = [];
if (myId != null) {
ranks.push({
id: myId,
nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
characterId: me.characterId || getPlayCharacterId(),
});
}
others.forEach((o, id) => {
if (!o) return;
ranks.push({
id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
characterId: o.characterId || null,
});
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
return ranks.slice(0, 5);
}
function cancelEmbedPreviewLobbyReturnTimer() {
if (embedPreviewLobbyReturnTimer != null) {
clearTimeout(embedPreviewLobbyReturnTimer);
embedPreviewLobbyReturnTimer = null;
}
}
function cancelQuizCarryResultEndAfterTimeup() {
if (quizCarryResultEndTimer != null) {
clearTimeout(quizCarryResultEndTimer);
quizCarryResultEndTimer = null;
}
cancelEmbedPreviewLobbyReturnTimer();
}
/** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */
function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() {
cancelEmbedPreviewLobbyReturnTimer();
if (!(previewMode && editorEmbedReturn)) return;
embedPreviewLobbyReturnTimer = setTimeout(function () {
embedPreviewLobbyReturnTimer = null;
window.location.href = buildRoomLobbyReturnHref();
}, 5000);
}
/** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */
function quizCarryAnyPlayerHasCorrectAnswer() {
if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false;
const keys = Object.keys(playLiveQuizScores);
for (let i = 0; i < keys.length; i++) {
if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true;
}
return false;
}
function hideQuizCarryResultEndLayer() {
cancelEmbedPreviewLobbyReturnTimer();
const el = document.getElementById('quiz-carry-result-end-layer');
if (!el) return;
el.classList.add('is-hidden');
el.setAttribute('aria-hidden', 'true');
}
function showQuizCarryResultEndLayer(useComplete) {
const el = document.getElementById('quiz-carry-result-end-layer');
const img = document.getElementById('quiz-carry-result-end-img');
if (!el || !img) return;
const fname = useComplete ? 'result-complete.png' : 'result-gameover.png';
img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
el.classList.remove('is-hidden');
el.setAttribute('aria-hidden', 'false');
}
/** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */
function scheduleEmbedDeskResultAfterDelay(okResolver) {
cancelQuizCarryResultEndAfterTimeup();
hideQuizCarryResultEndLayer();
const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; };
quizCarryResultEndTimer = setTimeout(function () {
quizCarryResultEndTimer = null;
let anyOk = false;
try {
anyOk = !!resolve();
} catch (e) {
anyOk = false;
}
hideQuizCarryTimeupOnDeskLayer();
showQuizCarryResultEndLayer(anyOk);
scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
}, 5000);
}
function hideQuizCarryTimeupOnDeskLayer() {
cancelQuizCarryResultEndAfterTimeup();
const el = document.getElementById('quiz-carry-timeup-desk-layer');
if (!el) return;
const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner');
if (anchor) {
anchor.style.left = '';
anchor.style.top = '';
anchor.style.width = '';
anchor.style.height = '';
}
el.classList.add('is-hidden');
el.setAttribute('aria-hidden', 'true');
}
/** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน
* @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */
function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) {
const el = document.getElementById('quiz-carry-timeup-desk-layer');
if (!el) return;
const img = document.getElementById('quiz-carry-timeup-txt-img');
if (img) {
if (isJumpSurviveMissionUiMapPlay()) {
img.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
};
img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
} else {
img.onerror = null;
img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
}
}
el.classList.remove('is-hidden');
el.setAttribute('aria-hidden', 'false');
try {
syncQuizCarryTimeupDeskLayerPosition();
} catch (e) { /* ignore */ }
if (previewMode && editorEmbedReturn) {
scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer);
}
}
function showQuizCarryMissionSummaryOverlay(opts) {
opts = opts || {};
const forceF = !!opts.forceGradeF;
const overlay = document.getElementById('quiz-carry-mission-overlay');
if (!overlay) return;
hideQuizCarryTimeupOnDeskLayer();
const ranks = quizCarryBuildMissionRankList();
const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0));
const total = parts.reduce((a, b) => a + b, 0);
const n = ranks.length;
const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0;
const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade);
const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade);
const rowEl = document.getElementById('qc-mission-rank-row');
const totalEl = document.getElementById('qc-mission-total-line');
const gradeEl = document.getElementById('qc-mission-grade');
const cardTextEl = document.getElementById('qc-mission-card-text');
const checkEl = document.getElementById('qc-mission-check');
const successTxt = document.getElementById('qc-mission-success-txt');
const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]'];
if (rowEl) {
rowEl.innerHTML = '';
ranks.forEach((r, idx) => {
const rank = idx + 1;
const cell = document.createElement('div');
cell.className = 'qc-mission-rank-cell';
const tag = document.createElement('div');
tag.className = 'qc-mission-rank-tag';
tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']');
const frame = document.createElement('div');
frame.className = 'qc-mission-rank-frame';
const img = document.createElement('img');
img.className = 'qc-mission-rank-avatar';
img.alt = '';
applyQuizCarryRankAvatarTint(img, r.characterId, r.id);
img.onerror = function () {
this.onerror = null;
this.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="%2318223b" width="64" height="64"/><text x="32" y="36" fill="%237aa2f7" font-size="10" text-anchor="middle">?</text></svg>');
};
frame.appendChild(img);
const nick = document.createElement('div');
nick.className = 'qc-mission-rank-nick';
nick.textContent = r.nickname;
nick.title = r.nickname;
const sc = document.createElement('div');
sc.className = 'qc-mission-rank-score';
sc.textContent = String(r.score);
cell.appendChild(tag);
cell.appendChild(frame);
cell.appendChild(nick);
cell.appendChild(sc);
rowEl.appendChild(cell);
});
}
if (totalEl) {
totalEl.textContent = '';
if (!n) {
totalEl.textContent = '—';
} else {
const lab = document.createElement('span');
lab.textContent = 'คะแนนรวม ';
const nums = document.createElement('span');
nums.className = 'qc-mission-total-nums';
nums.textContent = '(' + parts.join('+') + ') = ' + total;
const tail = document.createElement('span');
tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100';
totalEl.appendChild(lab);
totalEl.appendChild(nums);
totalEl.appendChild(tail);
}
}
if (gradeEl) {
gradeEl.textContent = grade;
gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase();
}
const okMission = grade !== 'F';
if (checkEl) checkEl.style.display = okMission ? '' : 'none';
if (successTxt) {
successTxt.style.display = okMission ? '' : 'none';
if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ';
}
if (cardTextEl) {
cardTextEl.textContent = '';
if (grade === 'F') {
const pl = document.createElement('div');
pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted';
const t = document.createElement('div');
t.className = 'qc-mission-reward-plaque-title';
t.textContent = 'ไม่ได้รับการ์ด';
const s = document.createElement('div');
s.className = 'qc-mission-reward-plaque-sub';
s.textContent = 'เกรด F';
pl.appendChild(t);
pl.appendChild(s);
cardTextEl.appendChild(pl);
} else if (reward) {
const pl = document.createElement('div');
pl.className = 'qc-mission-reward-plaque';
const t = document.createElement('div');
t.className = 'qc-mission-reward-plaque-title';
t.textContent = reward.th;
const s = document.createElement('div');
s.className = 'qc-mission-reward-plaque-sub';
s.textContent = reward.en;
pl.appendChild(t);
pl.appendChild(s);
cardTextEl.appendChild(pl);
}
}
overlay.classList.remove('is-hidden');
const btn = document.getElementById('btn-quiz-carry-mission-done');
if (btn) {
btn.onclick = function () {
overlay.classList.add('is-hidden');
if (previewMode && editorEmbedReturn) {
showQuizCarryTimeupOnDeskLayer();
} else {
window.location.href = buildRoomLobbyReturnHref();
}
};
}
}
function showQuizCarrySessionCompleteOverlay() {
if (quizCarrySessionEnded) return;
quizCarrySessionEnded = true;
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
hideQuizCarryEmbedCountdownOverlay();
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
playQuizText = 'ครบชุดคำถามแล้ว';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
const missionOv = document.getElementById('quiz-carry-mission-overlay');
if (missionOv && quizCarryUseMissionSummaryOverlay) {
showQuizCarryMissionSummaryOverlay({
forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated),
});
renderPlayQuizScoreboard(playLiveQuizScores);
return;
}
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) titleEl.textContent = 'เกมจบ · Game over';
msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({
id: myId,
nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
});
}
others.forEach((o, id) => {
if (!o) return;
ranks.push({
id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
});
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'}${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
renderPlayQuizScoreboard(playLiveQuizScores);
}
/** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */
function showQuizCarryToast(msg, ok) {
if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
window.__quizCarryToastT = null;
const el = document.getElementById('play-quiz-feedback');
if (!el) return;
el.classList.add('is-hidden');
el.className = 'play-quiz-feedback';
el.textContent = '';
}
/**
* หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
* @returns {boolean} ทำ action สำเร็จหรือไม่
*/
function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
opts = opts || {};
if (quizCarryPregameActive) return false;
if (quizCarrySessionEnded) return false;
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
const md = mapData;
const footprint = quizTilesFootprintPlay(ox, oy);
const ent = actorId === myId ? me : others.get(actorId);
if (!ent) return false;
let pickupIdx = null;
for (const k of footprint) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
}
const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
const correct = quizCarryCurrent.correctIndex;
let held = ent.quizCarryHeld;
if (held != null && canSubmitAnswer) {
if (!quizCarryOptionsPickableNow()) return false;
ent.quizCarryHeld = null;
const ok = held === correct;
if (ok) {
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
const showMapFb = !opts.silent && actorId === myId;
if (showMapFb) showQuizMapQuestionCarryFeedback(true);
if (!opts.silent) {
const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
}
me.quizCarryHeld = null;
others.forEach((o) => { if (o) o.quizCarryHeld = null; });
const afterOk = () => { quizCarryAfterRoundResolved(); };
if (showMapFb) {
window.setTimeout(afterOk, 560);
} else {
afterOk();
}
} else {
if (!opts.silent && actorId === myId) {
showQuizMapQuestionCarryFeedback(false);
}
if (!opts.silent) {
showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
}
}
renderPlayQuizScoreboard(playLiveQuizScores);
return true;
}
if (held == null && pickupIdx != null) {
if (!quizCarryOptionsPickableNow()) return false;
if (quizCarryOptionHeldByAnyone(pickupIdx)) {
if (opts.fromKey && actorId === myId && !opts.silent) {
showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
}
return false;
}
ent.quizCarryHeld = pickupIdx;
const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
if (!opts.silent) {
showQuizCarryToast('หยิบ: ' + label, true);
}
return true;
}
if (opts.fromKey && actorId === myId && !opts.silent) {
const t = Date.now();
if (t - (me.quizCarryHintT || 0) > 2200) {
me.quizCarryHintT = t;
if (held != null && !canSubmitAnswer) {
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
: 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
} else {
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
: 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
}
}
}
return false;
}
function quizCarryGrabCoreGatesOk() {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
if (quizCarryPregameActive || quizCarrySessionEnded) return false;
if (myId == null) return false;
if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
if (!quizCarryOptionsPickableNow()) return false;
return true;
}
/** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */
function getQuizCarryLocalGrabbableOptionIndex() {
if (!quizCarryGrabCoreGatesOk()) return null;
if (me.quizCarryHeld != null) return null;
const md = mapData;
const footprint = quizTilesFootprintPlay(me.x, me.y);
let pickupIdx = null;
for (const k of footprint) {
const p = k.split(',');
const tx = +p[0];
const ty = +p[1];
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
}
if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null;
return pickupIdx;
}
/** ยืนบนช่องตัวเลือกและหยิบได้ */
function quizCarryGrabPickupAvailable() {
return getQuizCarryLocalGrabbableOptionIndex() != null;
}
/** ถือป้ายแล้วยืนโซนส่งได้ */
function quizCarryGrabSubmitAvailable() {
if (!quizCarryGrabCoreGatesOk()) return false;
if (me.quizCarryHeld == null) return false;
return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
}
/** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
function quizCarryGrabInteractionAvailable() {
return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
}
function syncQuizCarryGrabButton() {
const btn = document.getElementById('quiz-carry-grab-btn');
if (!btn) return;
if (!isQuizCarry() || !mapData) {
btn.classList.add('is-hidden');
btn.classList.remove('quiz-carry-grab-btn--active');
btn.classList.remove('quiz-carry-grab-btn--place');
btn.setAttribute('aria-disabled', 'true');
return;
}
const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
const img = btn.querySelector('img');
btn.classList.remove('is-hidden');
const active = quizCarryGrabInteractionAvailable();
btn.classList.toggle('quiz-carry-grab-btn--active', active);
btn.setAttribute('aria-disabled', active ? 'false' : 'true');
btn.style.pointerEvents = active ? '' : 'none';
if (img) {
if (active && quizCarryGrabSubmitAvailable()) {
img.src = placeSrc;
btn.classList.add('quiz-carry-grab-btn--place');
btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
} else {
img.src = grabSrc;
btn.classList.remove('quiz-carry-grab-btn--place');
btn.title = 'หยิบตัวเลือก — เหมือนกด F';
btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
}
}
}
function resetQuizCarryPlayState() {
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryPool = [];
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownStartAt = 0;
quizCarryEmbedCountdownEndAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
quizCarryRoundsCompleted = 0;
quizCarrySessionEnded = false;
hideQuizCarryEmbedCountdownOverlay();
hideQuizCarryTimeupOnDeskLayer();
hideQuizCarryResultEndLayer();
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
}
/** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
async function mergeQuizCarrySettingsFromDisk(s) {
const out = s && typeof s === 'object' ? s : {};
try {
const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
if (rd.ok) {
const disk = await rd.json();
if (disk && typeof disk === 'object') {
if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
out.carryMapPanelTheme = disk.carryMapPanelTheme;
}
if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
}
if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
} else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
}
if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
if (Number.isFinite(diskPlaqueScale)) {
out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
}
if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
out.carryQuestions = disk.carryQuestions;
}
}
}
} catch (e) { /* ignore */ }
return out;
}
async function loadQuizCarryPoolAndStart() {
quizCarryRoundsCompleted = 0;
quizCarrySessionEnded = false;
hideQuizCarryTimeupOnDeskLayer();
hideQuizCarryResultEndLayer();
quizCarrySessionLength = 0;
let pool = [];
let s = {};
const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null;
try {
const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
if (r.ok) {
const raw = await r.text();
const trimmed = (raw || '').trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') s = parsed;
}
}
} catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ }
try {
s = await mergeQuizCarrySettingsFromDisk(s);
} catch (e) { /* ignore */ }
if (snap) {
if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs;
if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs;
if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength;
}
if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') {
s.carryMapPanelTheme = snap.carryMapPanelTheme;
}
if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') {
s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme;
}
/* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */
if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) {
if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) {
s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes;
} else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') {
s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme;
}
}
if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) {
const sm = Number(snap.carryChoicePlaqueMapScale);
if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm;
}
{
const sc = Number(s.carryChoicePlaqueMapScale);
quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25;
}
try {
quizCarryCarryTimingMs = {
carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
};
quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
setQuizCarryMapPanelThemeFromApi(s);
setQuizCarryEmbedCountdownThemeFromApi(s);
setQuizCarryChoicePlaqueThemeFromApi(s);
for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) {
const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl);
if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue;
const pim = new Image();
quizCarryApplyImageCrossOrigin(pim, tUrl);
pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
quizCarryChoiceImageCache.set(tUrl, pim);
try {
pim.src = tUrl;
} catch (e) { /* ignore */ }
}
const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
for (let i = 0; i < source.length; i++) {
const n = normalizeQuizCarryQuestionFromAny(source[i]);
if (n) pool.push(n);
}
} catch (e) { /* map only */ }
if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
quizCarryPool = pool;
for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]);
playLiveQuizScores = {};
if (myId != null) playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
if (!pool.length) {
playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
try {
syncPlayQuizMapPanel();
} catch (e) { /* ignore */ }
quizCarryJoinSettingsSnap = null;
return;
}
if (previewMode && editorEmbedReturn) {
beginQuizCarryEmbedPregame();
} else if (isDetectiveMinigamePlay()) {
quizCarryPickNextQuestion();
} else {
quizCarryPickNextQuestion();
}
if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) {
quizCarryRestoreEmbedPhaseLabel();
}
try {
syncPlayQuizMapPanel();
} catch (e) { /* ignore */ }
quizCarryJoinSettingsSnap = null;
}
function setupPlayQuizCarryUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) {
if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
} else {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
}
const tEl = document.getElementById('quiz-game-timer');
if (tEl) tEl.textContent = '';
const leg = document.getElementById('quiz-play-legend');
if (leg) {
leg.textContent = isDetectiveMinigamePlay()
? ''
: (quizCarryMapHasAnswerInteractive(mapData)
? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
: 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย');
}
resetQuizCarryPlayState();
if (previewMode) {
loadQuizCarryPoolAndStart();
} else {
playQuizText = 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
loadQuizCarryPoolAndStart();
}
renderPlayQuizScoreboard(playLiveQuizScores);
syncQuizCarryGrabButton();
}
function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
const w = md.width || 20, h = md.height || 15;
const want = optionIndex + 1;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileInQuizCarryHub(md, o) {
const w = md.width || 20, h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileInQuizCarryInteractive(md, o) {
const w = md.width || 20, h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.interactive && md.interactive[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileForQuizCarrySubmit(md, o) {
if (quizCarryMapHasAnswerInteractive(md)) {
const p = pickRandomTileInQuizCarryInteractive(md, o);
if (p) return p;
}
return pickRandomTileInQuizCarryHub(md, o);
}
/** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */
function quizCarryPreviewBotLobbyWanderStep(o, w, h) {
const now = Date.now();
o.botIsWalking = false;
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
if (now >= o.botWanderNextTurn) {
o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
if (Math.random() < 0.55) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
}
const accX = o.botWanderDx;
const accY = o.botWanderDy;
if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
const step = MOVE_SPEED;
const nx = o.x + accX * step;
const ny = o.y + accY * step;
const ox = o.x;
const oy = o.y;
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else {
if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
} else {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
}
}
if (!Number.isFinite(o.x)) o.x = 0.5;
if (!Number.isFinite(o.y)) o.y = 0.5;
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
}
function stepQuizCarryPreviewBots() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!playBotsEnabled() || !isQuizCarry() || !mapData) return;
const w = mapData.width || 20, h = mapData.height || 15;
const nowPf = performance.now();
const pathfindMinGap = editorEmbedReturn ? 280 : 140;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
o.x = sn.x;
o.y = sn.y;
o.botPath = [];
o.botPathStuckTicks = 0;
o.botQuizCarryPathfindAfter = 0;
}
}
if (!o.botPath || o.botPath.length === 0) {
tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
}
/* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */
if (!quizCarryCurrent) {
quizCarryPreviewBotLobbyWanderStep(o, w, h);
return;
}
const tier = o.botTier || 'avg';
const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
: Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
if (!o.botPath || !o.botPath.length) {
if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
if (nowPf < o.botQuizCarryPathfindAfter) {
quizCarryPreviewBotLobbyWanderStep(o, w, h);
return;
}
const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
if (quizCarryOptionsPickableNow()) {
if (o.quizCarryHeld == null && wantIdx != null) {
const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
if (dest) {
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
if (path && path.length > 1) o.botPath = path.slice(1);
}
} else {
const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
if (sub) {
const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
if (path && path.length > 1) o.botPath = path.slice(1);
}
}
o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
} else {
o.botQuizCarryPathfindAfter = nowPf + 120;
quizCarryPreviewBotLobbyWanderStep(o, w, h);
}
} else {
stepPreviewBotAlongPath(o, w, h);
}
clampPlayEntityFootprintToMap(o, mapData);
});
separateClumpedPreviewBots();
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const ob = others.get(bid);
if (ob) clampPlayEntityFootprintToMap(ob, mapData);
});
}
function getStackAreaBoundsPlay(area, w, h) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let any = false;
for (let y = 0; y < h; y++) {
const row = area && area[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1) {
any = true;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
}
if (!any) return null;
return {
minX, maxX, minY, maxY,
cx: (minX + maxX + 1) / 2,
cy: (minY + maxY + 1) / 2,
};
}
function getStackCameraCentersPx() {
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
let cx = (w / 2) * ts;
let cy = (h / 2) * ts;
if (land && rel) {
cx = land.cx * ts;
cy = ((land.cy + rel.cy) / 2) * ts;
} else if (land) {
cx = land.cx * ts;
cy = land.cy * ts;
} else if (rel) {
cx = rel.cx * ts;
cy = rel.cy * ts;
}
return { px: cx, py: cy };
}
/** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
function reapplyStackMiniSizingFromGlobals() {
if (!mapData || !isStack() || !stackMini) return;
if (stackMini.layers && stackMini.layers.length > 0) return;
if (stackFall || stackMini.settling) return;
const wMap = mapData.width || 20;
const hMap = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
: autoW;
stackMini.initialWidthTiles = iw;
stackMini.widthTiles = iw;
}
function resetStackMinigameState() {
stackMini = null;
if (!mapData || !isStack()) return;
const w = mapData.width || 20, h = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
let towerCX = w * 0.5;
let swingAmp = 1.85;
let floorWorldY = (h - 1) * tileSize;
let swingWorldY = floorWorldY - tileSize * 1.35;
if (land) {
towerCX = land.cx;
floorWorldY = (land.maxY + 1) * tileSize;
const span = Math.max(1, land.maxX - land.minX + 1);
swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
}
if (rel) {
swingWorldY = (rel.cy + 0.5) * tileSize;
const spanR = Math.max(1, rel.maxX - rel.minX + 1);
swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
}
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
: autoW;
stackFall = null;
stackMini = {
topCenterX: towerCX,
widthTiles: initW,
initialWidthTiles: initW,
craneWorldX: towerCX * tileSize,
swingAmp,
phase: Math.random() * Math.PI * 2,
phaseSpeed: playStackSwingHz,
floorWorldY,
swingWorldY,
layerWorldH: Math.max(14, tileSize * 0.3),
blockStripH: Math.max(16, tileSize * 0.32),
layers: [],
lives: 3,
score: 0,
combo: 0,
lastDropAt: 0,
stackTiltRad: 0,
stackTiltVel: 0,
};
if (previewFillBots) {
me.stackPreviewHumanPts = 0;
stackPreviewHudLog = [];
}
}
function stackEaseDrop(t) {
t = Math.max(0, Math.min(1, t));
return t * t;
}
/** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
function stackEaseDropPhys(t, sloppyN) {
t = Math.max(0, Math.min(1, t));
if (sloppyN < 0.14) return t * t;
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* @param {object} hit
* @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
*/
function applyStackHitFromDrop(hit, previewAttribution) {
if (!stackMini || !hit) return;
if (hit.miss) {
stackMini.lives = Math.max(0, stackMini.lives - 1);
stackMini.combo = 0;
if (stackMini.lives <= 0) {
stackMini.score = Math.max(0, stackMini.score - 2);
stackMini.lives = 3;
stackMini.layers = [];
stackMini.widthTiles = stackMini.initialWidthTiles;
const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
stackMini.combo = 0;
stackMini.stackTiltRad = 0;
stackMini.stackTiltVel = 0;
}
} else {
const W = stackMini.initialWidthTiles;
stackMini.layers.push({ w: W, cx: hit.placedCx });
stackMini.score += hit.pts;
if (previewFillBots && previewAttribution) {
if (previewAttribution.human) {
me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
} else if (previewAttribution.botId) {
const ob = others.get(previewAttribution.botId);
if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
}
}
stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
stackMini.widthTiles = W;
stackMini.topCenterX = hit.placedCx;
}
}
/**
* บล็อกทุกชั้นหน้ากว้างเท่ากัน (initialWidthTiles) · พลาดเมื่อทับแท่นไม่พอ
* วาดชั้นเป็นสี่เหลี่ยมแนวนอน (ไม่หมุนต่อชั้น) เพื่อกองตรง ไม่บิดเป็นกองไม้
*/
function computeStackDropResult(m) {
const raw = m.swingAmp * Math.sin(m.phase);
const wMap = mapData.width || 20;
const hMap = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
const fallW = m.initialWidthTiles;
let supportCx;
let supportW;
if (m.layers.length === 0) {
supportCx = land ? land.cx : wMap * 0.5;
supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
} else {
const sup = m.layers[m.layers.length - 1];
supportCx = sup.cx;
supportW = fallW;
}
const c1 = m.topCenterX + raw;
const half1 = fallW * 0.5;
const half0 = supportW * 0.5;
const left = Math.max(supportCx - half0, c1 - half1);
const right = Math.min(supportCx + half0, c1 + half1);
const overlap = right - left;
const missThreshold =
m.layers.length === 0
? Math.max(0.32, fallW * 0.11)
: Math.max(0.26, fallW * 0.17);
const miss = overlap < missThreshold;
const placedCx = c1;
const delta = c1 - supportCx;
const span = supportW * 0.5 + fallW * 0.5;
const offN = Math.abs(delta) / Math.max(0.01, span);
const perfect = !miss && offN < 0.075;
const bonus = perfect ? Math.min(4, m.combo || 0) : 0;
const pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
const supportRatio = fallW > 0 ? overlap / fallW : 0;
return {
miss,
perfect,
pts,
landOffsetTiles: raw,
placedW: fallW,
placedCx,
overlap,
supportRatio,
delta,
};
}
/**
* เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
* @param {object} hit จาก computeStackDropResult
* @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
* @returns {boolean}
*/
function startStackFallAnimation(hit, previewActor) {
if (!stackMini || !hit || stackFall || stackMini.settling) return false;
const m = stackMini;
const tw = m.widthTiles * tileSize;
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
if (y1 <= m.swingWorldY) y1 = m.swingWorldY + lh * 0.55;
const landOff = hit.landOffsetTiles || 0;
const landCxWorld = (m.topCenterX + landOff) * tileSize;
const xLeft0 = swingXW - tw / 2;
const xLeft1 = landCxWorld - tw / 2;
const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
const dur = Math.max(400, Math.min(1280, 240 + (y1 - m.swingWorldY) * 0.92 + offN * 560));
const tiltMax = hit.miss
? 0.28 * (0.25 + offN)
: 0.16 * offN * (hit.perfect ? 0.1 : 1);
const isHumanPreview = !!(previewActor && previewActor.human);
const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
stackFall = {
t0: performance.now(),
dur,
xLeft0,
xLeft1,
tw,
y0: m.swingWorldY,
y1,
hit,
tiltMax,
sloppyN: offN,
previewHumanDrop: isHumanPreview,
previewBotId: botIdPreview,
};
return true;
}
/** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
function tryHumanStackDrop() {
if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount)) {
rebuildStackPreviewTurnOrder();
}
if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
const now = Date.now();
if (now - stackMini.lastDropAt < 480) return false;
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
if (playBotsEnabled()) {
lastStackPreviewActorId = '__human__';
lastStackPreviewActorUntil = Date.now() + 900;
}
return true;
}
/** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
function simulatePreviewBotStackDrop(botId, o) {
if (!stackMini || stackFall || stackMini.settling) return;
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
lastStackPreviewActorId = botId;
lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
}
/** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
function stepPreviewStackTurnBased(nowMs) {
if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
if (mapData && mapData.gameType === 'stack' &&
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== stackTurnCount)) {
rebuildStackPreviewTurnOrder();
}
const order = stackPreviewTurnOrder;
if (!order || order.length !== stackTurnCount) return;
const cur = order[stackPreviewTurnIndex];
if (!cur || cur.kind !== 'bot') return;
if (!cur.botId) {
advanceStackPreviewTurn();
return;
}
const o = others.get(cur.botId);
if (!o) {
advanceStackPreviewTurn();
return;
}
if (stackPreviewBotThinkUntil === 0) {
stackPreviewBotThinkUntil = nowMs + 320 + Math.random() * 480;
return;
}
if (nowMs < stackPreviewBotThinkUntil) return;
stackPreviewBotThinkUntil = 0;
simulatePreviewBotStackDrop(cur.botId, o);
}
function stackTickFrame() {
const now = performance.now();
const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
lastStackTickMs = now;
if (stackMini && stackMini.settling) {
const s = stackMini.settling;
const u = (now - s.t0) / s.dur;
if (u >= 1) {
const wasHuman = !!s.previewHumanDrop;
const settleBotId = s.previewBotId || null;
applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
if (playBotsEnabled() && (wasHuman || settleBotId)) {
stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
}
stackMini.settling = null;
stackMini.lastDropAt = Date.now();
if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn();
}
} else if (stackFall) {
const t = (now - stackFall.t0) / stackFall.dur;
if (t >= 1) {
const wasHuman = !!stackFall.previewHumanDrop;
const fallBotId = stackFall.previewBotId || null;
const hit = stackFall.hit;
const stableMin = 0.52;
if (hit.miss) {
applyStackHitFromDrop(hit);
if (playBotsEnabled() && (wasHuman || fallBotId)) {
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
}
stackMini.lastDropAt = Date.now();
stackFall = null;
if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
} else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
stackMini.settling = {
t0: performance.now(),
dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
hit,
xLeft0: stackFall.xLeft1,
yTop0: stackFall.y1,
twWorld: stackFall.tw,
stripH,
delta: hit.delta || 0,
previewHumanDrop: wasHuman,
previewBotId: fallBotId,
};
stackFall = null;
} else {
let previewAttr;
if (playBotsEnabled()) {
if (wasHuman) previewAttr = { human: true };
else if (fallBotId) previewAttr = { botId: fallBotId };
}
applyStackHitFromDrop(hit, previewAttr);
if (playBotsEnabled() && (wasHuman || fallBotId)) {
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
}
stackMini.lastDropAt = Date.now();
stackFall = null;
if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
}
}
} else if (stackMini) {
stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
}
stepPreviewStackTurnBased(Date.now());
}
function applyStackPreviewSpawnLayout() {
if (!mapData || mapData.gameType !== 'stack') return;
const w = mapData.width || 20, h = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const cx = land ? land.cx : w / 2 - 0.5;
const cy = land ? land.cy : h / 2 - 0.5;
me.x = cx;
me.y = cy;
me.tx = cx;
me.ty = cy;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.x = cx + (Math.random() - 0.5) * 0.15;
o.y = cy + (Math.random() - 0.5) * 0.15;
o.tx = o.x;
o.ty = o.y;
o.stackBotScore = 0;
o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
});
}
function drawStackMinigame(ctx, worldToScreen, zoom) {
if (!stackMini) return;
const m = stackMini;
const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
const floorY = m.floorWorldY;
const guideTopY = Math.max(0, m.swingWorldY - tileSize * 6.5);
const [gtx, gty] = worldToScreen(m.topCenterX * tileSize, guideTopY);
const [gbx, gby] = worldToScreen(m.topCenterX * tileSize, floorY);
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = Math.max(1, zoom * 0.85);
ctx.setLineDash([5, 7]);
ctx.beginPath();
ctx.moveTo(gtx, gty);
ctx.lineTo(gbx, gby);
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
const nLay = m.layers.length;
for (let i = 0; i < nLay; i++) {
const L = m.layers[i];
const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize;
const cxW = L.cx * tileSize;
const yb = floorY - (i + 1) * layerWorldH;
const xl = cxW - lw / 2;
const [sx, sy] = worldToScreen(xl, yb);
const drawW = lw * zoom;
const drawH = layerWorldH * zoom;
const hue = (i * 47) % 360;
ctx.fillStyle = 'hsla(' + hue + ', 68%, 56%, 0.9)';
ctx.fillRect(sx, sy, drawW, drawH);
ctx.strokeStyle = 'rgba(255,255,255,0.38)';
ctx.lineWidth = 1;
ctx.strokeRect(sx, sy, drawW, drawH);
}
if (m.settling) {
const s = m.settling;
const u = Math.min(1, (performance.now() - s.t0) / s.dur);
const ue = u * u;
const rotSign = s.delta >= 0 ? 1 : -1;
const rotRad = ue * 1.22 * rotSign;
const yFall = ue * ue * tileSize * 6.5;
const xSlide = rotSign * ue * tileSize * 0.62;
const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
const [cxs, cys] = worldToScreen(cxW, cyW);
const drawW = s.twWorld * zoom;
const drawH = s.stripH * zoom;
const hue = (m.layers.length * 47) % 360;
ctx.save();
ctx.translate(cxs, cys);
ctx.rotate(rotRad);
ctx.fillStyle = 'hsla(' + hue + ', 70%, 52%, 0.93)';
ctx.fillRect(-drawW / 2, -drawH / 2, drawW, drawH);
ctx.strokeStyle = 'rgba(255, 200, 140, 0.9)';
ctx.lineWidth = 2;
ctx.strokeRect(-drawW / 2, -drawH / 2, drawW, drawH);
ctx.restore();
} else {
const twWorld = m.widthTiles * tileSize;
const drawStripPx = stripH * zoom;
let blockLeftWorld;
let blockTopWorld;
let fallTiltRad = 0;
if (stackFall) {
const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
const t = Math.min(1, tRaw);
const u = stackEaseDropPhys(t, stackFall.sloppyN);
blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
} else {
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
blockLeftWorld = swingXW - twWorld / 2;
blockTopWorld = m.swingWorldY;
const cableTopY = Math.max(0, m.swingWorldY - tileSize * 6);
const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
const cxMid = blockLeftWorld + twWorld / 2;
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
ctx.strokeStyle = 'rgba(90, 88, 86, 0.75)';
ctx.lineWidth = Math.max(2.2, zoom * 1.4);
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(tcx, tcy);
ctx.lineTo(bcx, bcy);
ctx.stroke();
ctx.strokeStyle = 'rgba(125, 207, 255, 0.35)';
ctx.lineWidth = Math.max(1, zoom * 0.65);
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.moveTo(tcx, tcy);
ctx.lineTo(bcx, bcy);
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
}
const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
ctx.fillStyle = hitMissTint ? 'rgba(247, 118, 190, 0.88)' : 'rgba(125, 207, 255, 0.95)';
ctx.strokeStyle = hitMissTint ? 'rgba(255, 180, 210, 0.98)' : 'rgba(192, 202, 245, 0.98)';
const cxW = blockLeftWorld + twWorld / 2;
const cyW = blockTopWorld + stripH * 0.5;
const [cxs, cys] = worldToScreen(cxW, cyW);
ctx.save();
ctx.translate(cxs, cys);
ctx.rotate(fallTiltRad);
ctx.fillRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
ctx.lineWidth = 2;
ctx.strokeRect(-twWorld * zoom / 2, -drawStripPx / 2, twWorld * zoom, drawStripPx);
ctx.lineWidth = 1;
ctx.restore();
}
const [fsx, fsy] = worldToScreen(0, floorY);
const [fex, fey] = worldToScreen(mapData.width * tileSize, floorY);
ctx.strokeStyle = 'rgba(247, 118, 190, 0.65)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(fsx, fsy);
ctx.lineTo(fex, fey);
ctx.stroke();
ctx.lineWidth = 1;
}
function applyGauntletTimingFromServer(payload) {
if (!payload || typeof payload !== 'object') return;
const tm = Number(payload.gauntletTickMs);
if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
const jt = Number(payload.gauntletJumpTicks);
if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
const tl = Number(payload.gauntletTimeLimitSec);
if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
else {
const ge = Number(payload.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) {
gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
if (!gauntletCrownRunHeldRemote && isGauntletCrownHeistMapPlay()) {
gauntletCrownPregamePhase = 'live';
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
gauntletLaneImageUrls = payload.gauntletLaneImageUrls
.filter((x) => typeof x === 'string')
.map((x) => normalizeGauntletAssetUrlForPlay(x))
.filter((x) => x)
.slice(0, 24);
gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '');
ensureGauntletAssetImage(gauntletLaserTopUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '');
ensureGauntletAssetImage(gauntletLaserBottomUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '');
ensureGauntletAssetImage(gauntletLaserLineUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
const lw = Number(payload.gauntletLaserLineWidthPx);
if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
const sh = Number(payload.stackSwingHz);
if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
const raw = payload.stackBlockWidthTiles;
if (raw == null || raw === '') playStackBlockWidthTiles = null;
else {
const nw = Number(raw);
playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
: null;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
const jm = Number(payload.jumpSurviveJumpHeightMult);
if (Number.isFinite(jm)) {
playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) {
const jt = Number(payload.jumpSurviveMissionTimeSec);
if (Number.isFinite(jt) && jt > 0) {
playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt)));
} else {
playJumpSurviveMissionTimeSec = 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) {
const st = Number(payload.spaceShooterMissionTimeSec);
if (Number.isFinite(st) && st > 0) {
playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
} else {
playSpaceShooterMissionTimeSec = 0;
}
}
if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
}
/** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
const GAUNTLET_VIS_LERP = 0.42;
function gauntletObsSmoothstep(t) {
t = Math.max(0, Math.min(1, t));
return t * t * (3 - 2 * t);
}
function cloneGauntletObsSnap(arr) {
if (!Array.isArray(arr)) return [];
return arr.map((o) => {
if (!o || o.id == null) return null;
const row = { id: o.id, kind: o.kind, x: o.x, y: o.y };
if (o.kind === 'laser') {
if (o.y0 != null) row.y0 = o.y0;
if (o.y1 != null) row.y1 = o.y1;
}
return row;
}).filter(Boolean);
}
function gauntletLaserRowHitRange(ob, mapH) {
const h = Math.max(1, Math.floor(Number(mapH)) || 15);
let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0;
let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1;
y0 = Math.max(0, Math.min(h - 1, y0));
y1 = Math.max(0, Math.min(h - 1, y1));
if (y1 < y0) {
const t = y0;
y0 = y1;
y1 = t;
}
return { y0, y1 };
}
function gauntletLaserOverlapsPlayerRow(ob, py, mapH) {
const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH);
return py >= y0 && py <= y1;
}
/** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
function gauntletLiftHeightNorm(air, jumpTicksMax) {
const jm = Math.max(4, jumpTicksMax || 16);
const a = Math.max(0, Math.min(jm, Number(air) || 0));
const t = a / jm;
const peakAt = 0.28;
if (t >= peakAt) {
const span = 1 - peakAt;
const u = span > 0 ? (t - peakAt) / span : 0;
return Math.max(0, 1 - u * u * 1.2);
}
return Math.min(1, t / peakAt);
}
function getGauntletObsDrawPositionsAt(nowMs) {
if (!gauntletObsRenderNext.length) return [];
const elapsed = nowMs - gauntletObsBlendT0;
const alpha = gauntletObsSmoothstep(elapsed / gauntletRuntimeTickMs);
const prevMap = new Map(gauntletObsRenderPrev.map((o) => [o.id, o]));
const out = [];
for (let i = 0; i < gauntletObsRenderNext.length; i++) {
const n = gauntletObsRenderNext[i];
if (!n) continue;
const p = prevMap.get(n.id);
let drawX = n.x;
if (p && Number.isFinite(p.x) && Number.isFinite(n.x)) drawX = p.x + (n.x - p.x) * alpha;
const row = { id: n.id, kind: n.kind, drawX, y: n.y };
if (n.kind === 'laser') {
if (n.y0 != null) row.y0 = n.y0;
if (n.y1 != null) row.y1 = n.y1;
}
out.push(row);
}
return out;
}
function pushGauntletObsRenderFrame(newObs) {
const now = performance.now();
const next = cloneGauntletObsSnap(newObs);
if (!gauntletObsRenderNext.length) {
gauntletObsRenderPrev = next.slice();
gauntletObsRenderNext = next.slice();
} else {
const curDraw = getGauntletObsDrawPositionsAt(now);
const curMap = new Map(curDraw.map((o) => [o.id, o.drawX]));
gauntletObsRenderPrev = next.map((n) => {
const row = {
id: n.id,
kind: n.kind,
x: curMap.has(n.id) ? curMap.get(n.id) : n.x,
y: n.y,
};
if (n.kind === 'laser') {
if (n.y0 != null) row.y0 = n.y0;
if (n.y1 != null) row.y1 = n.y1;
}
return row;
});
gauntletObsRenderNext = next;
}
gauntletObsBlendT0 = now;
}
function lerpGauntletEntityPos(x, y, tx, ty) {
let nx = x;
let ny = y;
if (tx != null && Number.isFinite(tx)) {
nx += (tx - nx) * GAUNTLET_VIS_LERP;
if (Math.abs(tx - nx) < 0.02) nx = tx;
}
if (ty != null && Number.isFinite(ty)) {
ny += (ty - ny) * GAUNTLET_VIS_LERP;
if (Math.abs(ty - ny) < 0.02) ny = ty;
}
return { nx, ny };
}
/**
* บอท preview ใน Gauntlet: ตัดสินใจกระโดด (client-only; เซิร์ฟเวอร์ไม่รู้จักบอท)
* — ตอบสนองสิ่งกีดขวางที่ชนเซลล์เดียวกัน + กระโดดล่วงหน้าเมื่อ threat อยู่คอลัมน์ถัดไป
*/
function maybePreviewBotGauntletJump(o, obstacles, w, h) {
if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
let px = Math.floor(Number(o.x)) || 0;
let py = Math.floor(Number(o.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
if ((o.gauntletJumpTicks || 0) > 0) return;
const { ch: gauntletCh } = getCharacterFootprintWH(mapData);
let sameCell = false;
let incomingCol = false;
for (let i = 0; i < obstacles.length; i++) {
const obs = obstacles[i];
if (!obs) continue;
if (obs.kind === 'lane' && typeof obs.y === 'number') {
if (obs.x === px && obs.y >= py && obs.y < py + gauntletCh) sameCell = true;
if (obs.x === px + 1 && obs.y >= py && obs.y < py + gauntletCh) incomingCol = true;
}
if (obs.kind === 'laser' && typeof obs.x === 'number') {
if (obs.x === px && gauntletLaserOverlapsPlayerRow(obs, py, h)) sameCell = true;
if (obs.x === px + 1 && gauntletLaserOverlapsPlayerRow(obs, py, h)) incomingCol = true;
}
}
const tier = o.botTier || 'avg';
const roll = Math.random();
const pSame = tier === 'sharp' ? 0.96 : tier === 'weak' ? 0.62 : 0.86;
const pAhead = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.48 : 0.72;
if (sameCell && roll < pSame) {
o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
return;
}
if (incomingCol && roll < pAhead) o.gauntletJumpTicks = gauntletRuntimeJumpTicks;
}
/** หนึ่ง tick เดียวกับ runGauntletTick ฝั่งเซิร์ฟเวอร์ (เฉพาะ collision + เลื่อน x) */
function applyGauntletPhysicsToPreviewBot(o, obstacles, w, h) {
if (isGauntletCrownHeistMapPlay() && o.gauntletEliminated) return;
let px = Math.floor(Number(o.x)) || 0;
let py = Math.floor(Number(o.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const air = (o.gauntletJumpTicks || 0) > 0;
const { ch: gauntletCh2 } = getCharacterFootprintWH(mapData);
const crown = isGauntletCrownHeistMapPlay();
let advanceX = false;
let hitBack = false;
for (let i = 0; i < obstacles.length; i++) {
const ob = obstacles[i];
if (!ob) continue;
if (ob.kind === 'lane' && typeof ob.y === 'number' && ob.x === px && ob.y >= py && ob.y < py + gauntletCh2) {
if (air) advanceX = true;
else hitBack = true;
}
if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) {
if (!gauntletLaserOverlapsPlayerRow(ob, py, h)) {
/* เลเซอร์ไม่ครอบแถวนี้ */
} else if (air) {
advanceX = true;
} else {
hitBack = true;
}
}
}
if (advanceX) {
px = Math.min(w - 2, px + 1);
o.gauntletJumpTicks = 0;
if (!crown) {
o.gauntletScore = (o.gauntletScore || 0) + 1;
}
} else if (hitBack) {
if (crown) {
if (px <= 0) {
o.gauntletEliminated = true;
o.gauntletScore = 0;
} else {
o.gauntletScore = Math.max(0, (o.gauntletScore || 0) - 10);
px = Math.max(0, px - 1);
}
} else {
px = Math.max(0, px - 1);
}
}
if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
o.tx = px;
o.ty = py;
}
function applyGauntletPreviewBotsAfterSync() {
if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return;
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
const w = mapData.width || 20;
const h = mapData.height || 15;
others.forEach((o, id) => {
if (!isPreviewBotId(id) || !o) return;
maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
});
}
/** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
function emitGauntletPreviewRowsToServer() {
if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return;
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
const h = mapData.height || 15;
const ysSet = new Set();
const addY = (v) => {
const fy = Math.floor(Number(v));
if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
};
addY(me.y);
others.forEach((o, id) => {
if (isPreviewBotId(id)) addY(o.y);
});
socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
}
/** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */
function hideConflictingOverlaysForGauntletCrown() {
hideQuizCarryPregameOverlay();
quizCarryPregameActive = false;
const hideIds = [
'quiz-game-overlay',
'quiz-carry-mission-overlay',
'gauntlet-ended-overlay',
'quiz-battle-mcq-overlay',
'quiz-carry-embed-countdown',
'gauntlet-crown-countdown',
'quiz-carry-embed-q-strip',
'play-quiz-scoreboard',
'play-quiz-feedback',
];
for (let i = 0; i < hideIds.length; i++) {
const el = document.getElementById(hideIds[i]);
if (el) el.classList.add('is-hidden');
}
const gcm = document.getElementById('gauntlet-crown-mission-overlay');
if (gcm) gcm.classList.add('is-hidden');
}
function gcmRankBadgeUrl(rank) {
const r = Math.floor(Number(rank)) || 99;
if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png';
if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png';
if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png';
return BASE + '/img/gauntlet-assets/result-txt-2.png';
}
function gauntletCrownPregameReadyNumerator() {
let n = quizCarryPregameBotCount();
quizCarryPregameHumanIds().forEach((id) => {
if (gauntletCrownLobbyReadyMap[id]) n++;
});
return n;
}
function gauntletCrownSyncGuestReadyIfNeeded() {
if (gauntletCrownPregamePhase !== 'howto') return;
if (myId == null || isMePlayHost()) return;
const sid = String(myId);
if (gauntletCrownLobbyReadyMap[sid]) return;
gauntletCrownLobbyReadyMap[sid] = true;
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true });
}
function updateGauntletCrownHowtoHud() {
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!btn || !isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
if (st) {
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
}
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
function beginGauntletCrownCountdownThenRun() {
if (!isGauntletCrownHeistMapPlay()) return;
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = () => {
if (cd) cd.classList.add('is-hidden');
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
if (socket && socket.connected) {
socket.emit('gauntlet-crown-begin-run', (res) => {
if (res && res.ok) gauntletCrownPregamePhase = 'live';
});
} else {
gauntletCrownPregamePhase = 'live';
}
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
numEl.textContent = String(n);
const step = () => {
n--;
if (n > 0) {
numEl.textContent = String(n);
gauntletCrownCountdownTimer = setTimeout(step, 1000);
} else {
gauntletCrownCountdownTimer = null;
runFinish();
}
};
gauntletCrownCountdownTimer = setTimeout(step, 1000);
}
function showGauntletCrownHowtoOverlay() {
if (!isGauntletCrownHeistMapPlay()) return;
hideConflictingOverlaysForGauntletCrown();
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
gauntletCrownSyncGuestReadyIfNeeded();
updateGauntletCrownHowtoHud();
}
/** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */
function gauntletCrownEmbedMissionAnyOk(mission) {
if (!mission) return false;
if (mission.uiSkin === 'jumper') {
const sc = Number(mission.survivorCount);
return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F');
}
const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
return g !== 'F';
}
function gauntletCrownRankBonusLocal(rankOrdinal) {
if (rankOrdinal === 1) return 100;
if (rankOrdinal === 2) return 80;
if (rankOrdinal === 3) return 60;
return 0;
}
function gauntletCrownRollRewardCardLocal(grade) {
const r = Math.random();
if (grade === 'A') {
if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'B') {
if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
/**
* Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์
* — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row)
*/
function gauntletCrownBuildDisplayMission(mission) {
if (!mission || !Array.isArray(mission.ranked)) return mission;
if (mission.uiSkin === 'jumper') return mission;
if (!(previewFillBots && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission;
const seen = new Set();
const baseRows = [];
mission.ranked.forEach((r) => {
if (!r) return;
const sid = String(r.id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: r.id,
nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น',
characterId: r.characterId ?? null,
baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
});
});
others.forEach((o, id) => {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
eliminated: !!o.gauntletEliminated,
});
});
baseRows.sort((a, b) => {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
if (nick !== 0) return nick;
return String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map((row, idx) => {
const pos = idx + 1;
const rankBonus = gauntletCrownRankBonusLocal(pos);
const rankLabel = pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos);
const finalScore = row.baseScore + rankBonus;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: row.eliminated,
rank: pos,
rankLabel: rankLabel,
rankBonus: rankBonus,
finalScore: finalScore,
};
});
const totalSum = ranked.reduce(function (s, r) { return s + r.finalScore; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
const rewardCard = gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: ranked.map(function (r) { return r.finalScore; }),
};
}
function showGauntletCrownMissionOverlay(mission) {
const ov = document.getElementById('gauntlet-crown-mission-overlay');
const rowEl = document.getElementById('gcm-rank-row');
const totalEl = document.getElementById('gcm-total');
const gradeEl = document.getElementById('gcm-grade');
const bonusEl = document.getElementById('gcm-bonus');
const btn = document.getElementById('btn-gcm-done');
if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return;
let disp = gauntletCrownBuildDisplayMission(mission);
if (mission.uiSkin === 'jumper') {
disp = jumpSurviveMergePreviewBotsMission(disp) || disp;
}
if (!disp || !Array.isArray(disp.ranked)) return;
rowEl.innerHTML = '';
disp.ranked.forEach((r) => {
const cell = document.createElement('div');
cell.className = 'gcm-cell';
const tag = document.createElement('div');
tag.className = 'gcm-rank-tag';
const rk = Math.floor(Number(r.rank)) || 0;
if (rk >= 1 && rk <= 3) {
const tagImg = document.createElement('img');
tagImg.src = gcmRankBadgeUrl(rk);
tagImg.alt = '[' + String(r.rankLabel || rk) + ']';
tagImg.style.maxWidth = '100%';
tagImg.style.height = 'auto';
tag.appendChild(tagImg);
} else {
tag.textContent = '[' + String(r.rankLabel || rk) + ']';
}
const frame = document.createElement('div');
frame.className = 'gcm-frame';
const img = document.createElement('img');
img.className = 'gcm-av';
img.alt = '';
const cid = r.characterId ? String(r.characterId) : '';
if (cid) {
const im = getCharacterImg(cid, 'down');
if (im && im.src) img.src = im.src;
} else {
img.src = defaultAvatarImg.src;
}
img.onerror = function () {
this.onerror = null;
this.src = defaultAvatarImg.src;
};
frame.appendChild(img);
if (r.eliminated) {
const st = document.createElement('img');
st.className = 'gcm-stamp';
const jSkin = mission && mission.uiSkin === 'jumper';
st.src = jSkin ? jumperAssetUrl('stamp-sacrifice.png') : (BASE + '/img/gauntlet-assets/result-Lose_stamp.png');
st.alt = jSkin ? 'ผู้เสียสละ' : 'เสียชีวิต';
st.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
this.alt = 'เสียชีวิต';
};
frame.appendChild(st);
}
const nick = document.createElement('div');
nick.className = 'gcm-nick';
nick.textContent = r.nickname || '—';
const sc = document.createElement('div');
sc.className = 'gcm-sc';
const jumpPlain = mission && mission.uiSkin === 'jumper';
const botScorePlain = previewFillBots && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id);
sc.textContent = (jumpPlain || botScorePlain)
? String(Math.max(0, Number(r.baseScore) || 0))
: String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')';
cell.appendChild(tag);
cell.appendChild(frame);
cell.appendChild(nick);
cell.appendChild(sc);
rowEl.appendChild(cell);
});
if (totalEl) {
const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0)));
const sum = Math.max(0, Number(disp.totalSum) || 0);
const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0));
const jumperSkin = mission && mission.uiSkin === 'jumper';
if (jumperSkin) {
const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'คะแนนรวม <span class="gcm-total-nums">(' + parts.join('+') + ') = ' + sum + '</span> · ผู้รอด ' + sc + '/' + pc + ' → เกรด';
} else {
totalEl.innerHTML = 'คะแนนรวม <span class="gcm-total-nums">(' + parts.join('+') + ') = ' + sum + '</span> · เฉลี่ย ' + avg + ' → เกรด';
}
}
if (gradeEl) {
const g = String(disp.grade || 'C').toUpperCase().charAt(0);
gradeEl.textContent = g;
gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase();
}
if (bonusEl) {
bonusEl.innerHTML = '';
const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0);
const missionOk = gLetter !== 'F';
if (missionOk) {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '8px';
const chk = document.createElement('img');
chk.src = BASE + '/img/gauntlet-assets/result-check.png';
chk.alt = '';
chk.width = 26;
chk.height = 26;
const sp = document.createElement('span');
sp.style.color = '#9ece6a';
sp.style.fontWeight = '700';
sp.textContent = 'ภารกิจสำเร็จ';
row.appendChild(chk);
row.appendChild(sp);
bonusEl.appendChild(row);
}
const rc = disp.rewardCard;
if (missionOk && rc && rc.th) {
const pl = document.createElement('div');
pl.className = 'gcm-plaque';
pl.innerHTML = '<strong>' + String(rc.th) + '</strong><span>' + String(rc.en || '') + '</span>';
bonusEl.appendChild(pl);
} else if (!missionOk) {
const pl = document.createElement('div');
pl.className = 'gcm-plaque';
pl.style.borderColor = 'rgba(148, 153, 168, 0.45)';
pl.style.opacity = '0.95';
pl.innerHTML = '<strong>ไม่ได้รับการ์ด</strong><span>เกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์</span>';
bonusEl.appendChild(pl);
}
}
ov.classList.remove('is-hidden');
function goLobby() {
ov.classList.add('is-hidden');
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = function () {
if (previewMode && editorEmbedReturn) {
ov.classList.add('is-hidden');
showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); });
} else {
goLobby();
}
};
}
}
function isLobby() { return mapData && mapData.gameType === 'lobby'; }
function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
function getLane(y) {
if (!mapData || !mapData.lanes) return null;
const lane = mapData.lanes.find(l => l.y === y);
return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
}
function getVehiclePositions(lane, width, timeMs) {
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
const dir = lane.dir === -1 ? -1 : 1;
const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
const period = width + 2;
const count = Math.max(2, Math.floor(period / spacing));
const positions = [];
for (let i = 0; i < count; i++) {
const p = i * spacing + (timeMs * speed * dir) / 60;
const pNorm = ((p % period) + period) % period;
const vx = pNorm - 1;
positions.push(Math.max(-1, Math.min(width, vx)));
}
return positions;
}
function checkFroggerCollision() {
const fy = Math.floor(me.y);
const lane = getLane(fy);
if (!lane) return false;
if (lane.type === 'road') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.1) return true;
}
}
if (lane.type === 'water') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
let onLog = false;
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
}
if (!onLog) return true;
}
return false;
}
function respawnFrogger() {
const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
me.x = sp.x; me.y = sp.y;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
/** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
function peerXYFromJoin(peer, spawnFb) {
const sx = Number(spawnFb && spawnFb.x);
const sy = Number(spawnFb && spawnFb.y);
const defX = Number.isFinite(sx) ? sx : 1;
const defY = Number.isFinite(sy) ? sy : 1;
if (!peer) return { x: defX, y: defY };
const x = Number(peer.x);
const y = Number(peer.y);
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
return { x: defX, y: defY };
}
/** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
function normalizeQuizCarryLayersInPlay(md) {
if (!md || md.gameType !== 'quiz_carry') return;
const w = md.width || 20, h = md.height || 15;
const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
const srcHub = md.quizCarryHubArea || [];
const hubRows = [];
for (let y = 0; y < h; y++) {
const r = srcHub[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
hubRows.push(row);
}
md.quizCarryHubArea = hubRows;
const srcOpt = md.quizCarryOptionArea || [];
const optRows = [];
for (let y = 0; y < h; y++) {
const r = srcOpt[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
}
optRows.push(row);
}
md.quizCarryOptionArea = optRows;
const srcCd = md.carryEmbedCountdownArea || [];
const cdRows = [];
for (let y = 0; y < h; y++) {
const r = srcCd[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
row.push(Number(v) === 1 ? 1 : 0);
}
cdRows.push(row);
}
md.carryEmbedCountdownArea = cdRows;
}
/** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
function normalizeQuizBattleDomeInPlay(md) {
if (!md || md.gameType !== 'quiz_battle') return;
const w = md.width || 20, h = md.height || 15;
const src = md.quizBattleDomeArea || [];
const grid = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
grid.push(row);
}
md.quizBattleDomeArea = grid;
const comp = Array(h).fill(0).map(() => Array(w).fill(0));
let nextId = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (grid[y][x] !== 1 || comp[y][x]) continue;
nextId++;
const stack = [[x, y]];
comp[y][x] = nextId;
while (stack.length) {
const c = stack.pop();
const cx = c[0], cy = c[1];
for (let i = 0; i < 4; i++) {
const dx = (i === 0 ? 1 : i === 1 ? -1 : 0);
const dy = (i === 2 ? 1 : i === 3 ? -1 : 0);
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (grid[ny][nx] !== 1 || comp[ny][nx]) continue;
comp[ny][nx] = nextId;
stack.push([nx, ny]);
}
}
}
}
md.quizBattleDomeComp = comp;
}
/** เส้นทาง Quiz Battle — วาดอย่างน้อย 1 ช่องแล้วจะเดินได้เฉพาะบนเส้นทาง (เปลี่ยนแค่รูปพื้นหลังได้) */
function normalizeQuizBattlePathInPlay(md) {
if (!md || md.gameType !== 'quiz_battle') return;
const w = md.width || 20, h = md.height || 15;
const src = md.quizBattlePathArea || [];
const grid = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
grid.push(row);
}
md.quizBattlePathArea = grid;
}
function quizBattlePathModeActive(md) {
if (!md || md.gameType !== 'quiz_battle' || !md.quizBattlePathArea) return false;
const g = md.quizBattlePathArea;
for (let y = 0; y < g.length; y++) {
const row = g[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
function quizBattleFootprintFullyOnPath(md, px, py) {
if (!md || !quizBattlePathModeActive(md)) return true;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false;
const tiles = quizTilesFootprintPlay(px, py);
if (tiles.size === 0) return false;
const g = md.quizBattlePathArea;
for (const k of tiles) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (!g[ty] || g[ty][tx] !== 1) return false;
}
return true;
}
/** สแน็ปบนเลน — เช็คแค่กำแพง + อยู่บน path (ให้ตรงกับ server) ไม่ใช้ blockPlayer/ผู้เล่นคนอื่น เพราะจะทำให้หาจุดสแน็ปไม่ได้แล้วค้างนอกเลน */
function quizBattleSnapTargetValidPlay(x, y) {
if (!mapData || !mapData.objects) return false;
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const w = mapData.width || 20, h = mapData.height || 15;
for (const k of quizTilesWallCollisionFootprintPlay(x, y)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects[ty];
if (!row || row[tx] === 1) return false;
}
if (quizBattlePathModeActive(mapData) && !quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
return true;
}
function snapPositionOntoQuizBattlePathIfNeeded(px, py) {
if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return { x: px, y: py };
if (quizBattleSnapTargetValidPlay(px, py)) return { x: px, y: py };
const w = mapData.width || 20, h = mapData.height || 15;
const g = mapData.quizBattlePathArea;
let bestX = null, bestY = null, bestD = Infinity;
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const nx = tx + 0.01;
const ny = ty + 0.01;
if (!quizBattleSnapTargetValidPlay(nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
/* footprint ใหญ่กว่าเลน (เช่น 2×2 บนทางแคบ 1 ช่อง): หาช่อง path ใกล้สุดที่เดินได้อย่างน้อยที่เซ็นเตอร์ — ดีกว่าปล่อยค้างนอกเลน */
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
if (!spawnTileWalkablePlay(mapData, tx, ty)) continue;
const nx = tx + 0.5;
const ny = ty + 0.5;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
return { x: px, y: py };
}
/** บังคับให้ผู้เล่นอยู่บนเลน — เรียกก่อน return กลาง tick (แชท / path หมด) เพราะเดิมข้ามบล็อกสแน็ปท้าย tick */
function enforceQuizBattleLaneOnMePlay() {
if (!mapData || !isQuizBattle() || !quizBattlePathModeActive(mapData)) return;
if (quizBattleSnapTargetValidPlay(me.x, me.y)) return;
const snapped = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = snapped.x;
me.y = snapped.y;
}
function hideQuizBattleMcqModal() {
const ov = document.getElementById('quiz-battle-mcq-overlay');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
}
function flashQuizBattleFeedback(text, ok) {
const el = document.getElementById('play-quiz-feedback');
if (!el) return;
el.textContent = text || '';
el.classList.remove('is-hidden', 'play-quiz-feedback-ok', 'play-quiz-feedback-bad');
el.classList.add(ok ? 'play-quiz-feedback-ok' : 'play-quiz-feedback-bad');
if (typeof window.__quizBattleFbT === 'number') clearTimeout(window.__quizBattleFbT);
window.__quizBattleFbT = setTimeout(() => { el.classList.add('is-hidden'); }, 1800);
}
function getQuizBattleDomeCompIdsUnderFootprint(px, py) {
if (!mapData || !isQuizBattle()) return [];
const comp = mapData.quizBattleDomeComp;
const grid = mapData.quizBattleDomeArea;
if (!comp || !grid) return [];
const ids = [];
const seen = Object.create(null);
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
const cid = comp[ty] && comp[ty][tx];
if (grid[ty] && grid[ty][tx] === 1 && cid > 0 && !seen[cid]) {
seen[cid] = 1;
ids.push(cid);
}
}
return ids;
}
function getPrimaryQuizBattleDomeCompAt(px, py) {
const arr = getQuizBattleDomeCompIdsUnderFootprint(px, py);
if (!arr.length) return null;
return Math.min.apply(null, arr);
}
function showQuizBattleMcqModal(q) {
const ov = document.getElementById('quiz-battle-mcq-overlay');
const textEl = document.getElementById('quiz-battle-mcq-text');
if (!ov || !textEl || !q) return;
textEl.textContent = q.text || '';
const labels = ['A', 'B', 'C'];
for (let i = 0; i < 3; i++) {
const btn = ov.querySelector('.quiz-battle-choice[data-idx="' + i + '"]');
if (btn) {
const ch = (q.choices && q.choices[i]) ? String(q.choices[i]) : '';
btn.textContent = labels[i] + (ch ? (': ' + ch.slice(0, 96)) : '');
}
}
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
function trySubmitQuizBattleChoice(idx) {
if (quizBattleModalCompId == null || !mapData || !isQuizBattle()) return;
const compId = quizBattleModalCompId;
const pool = quizBattleMcqPool;
if (!pool.length) {
flashQuizBattleFeedback('ยังไม่มีคำถาม — ตั้ง battleQuizMcq ใน Admin → Quiz Battle', false);
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
return;
}
const q = pool[(compId - 1) % pool.length];
if (!q) return;
const ok = Number(q.correctIndex) === Number(idx);
if (ok) {
quizBattleAnsweredComps.add(compId);
if (myId != null) {
if (!playLiveQuizScores) playLiveQuizScores = {};
playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
renderPlayQuizScoreboard(playLiveQuizScores);
}
flashQuizBattleFeedback('ถูกต้อง · +1', true);
} else {
flashQuizBattleFeedback('ยังไม่ถูก — ลองใหม่ (กด E อีกครั้ง)', false);
}
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
}
function tryOpenQuizBattleFromKey() {
if (!isQuizBattle() || !mapData || myId == null) return;
const compId = getPrimaryQuizBattleDomeCompAt(me.x, me.y);
if (compId == null) {
flashQuizBattleFeedback('ยังไม่ยืนบนโดมคำถาม', false);
return;
}
if (quizBattleAnsweredComps.has(compId)) {
flashQuizBattleFeedback('ตอบโดมนี้ถูกแล้ว', false);
return;
}
const pool = quizBattleMcqPool;
if (!pool.length) {
flashQuizBattleFeedback('ยังไม่มีคำถามในระบบ (Admin → Quiz Battle)', false);
return;
}
const q = pool[(compId - 1) % pool.length];
if (!q) return;
quizBattleModalCompId = compId;
showQuizBattleMcqModal(q);
}
function pushSanitizedBattleMcqFromPayload(s) {
if (!s || !Array.isArray(s.battleQuizMcq)) return 0;
let n = 0;
for (let i = 0; i < s.battleQuizMcq.length; i++) {
const raw = s.battleQuizMcq[i];
if (!raw || !String(raw.text || '').trim()) continue;
const ch = Array.isArray(raw.choices) ? raw.choices : [];
if (ch.length !== 3) continue;
const a = String(ch[0] || '').trim();
const b = String(ch[1] || '').trim();
const c = String(ch[2] || '').trim();
if (!a || !b || !c) continue;
let ci = Number(raw.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(2, Math.floor(ci)));
quizBattleMcqPool.push({
text: String(raw.text).trim(),
choices: [a, b, c],
correctIndex: ci,
});
n++;
}
return n;
}
/**
* โหลดชุดข้อ A/B/C — ลอง Node ก่อน แล้ว fallback PHP อ่านจากดิสก์ (กรณี nginx ไม่ส่ง /api/quiz-settings ถึง Node)
*/
async function loadQuizBattleMcqPool() {
quizBattleMcqPool = [];
const bust = '_=' + Date.now();
const tryUrls = [
BASE + '/api/quiz-settings?' + bust,
BASE + '/api-quiz-battle-mcq.php?' + bust,
];
for (let u = 0; u < tryUrls.length; u++) {
try {
const r = await fetch(tryUrls[u], { cache: 'no-store' });
if (!r.ok) continue;
const s = await r.json();
if (pushSanitizedBattleMcqFromPayload(s) > 0) break;
} catch (e) { /* next url */ }
}
}
function resetQuizBattlePlayState() {
quizBattleMcqPool = [];
quizBattleAnsweredComps = new Set();
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
}
function setupPlayQuizBattleUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
/* ทดสอบจากเอดิเตอร์: อย่าเปิดแผงคำถามเต็มจอล่าง — บังมองเห็นบอท/แผนที่ (overlay ยังอัปเดตข้อความไว้ถ้าเปิด preview แบบไม่ embed) */
if (ov) {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else ov.classList.remove('is-hidden');
}
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'Quiz Battle';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = 'เดินทับโดม (สีฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C';
const tEl = document.getElementById('quiz-game-timer');
if (tEl) tEl.textContent = '';
const leg = document.getElementById('quiz-play-legend');
if (leg) {
leg.textContent = quizBattlePathModeActive(mapData)
? 'โหมดเส้นทาง: เดินได้เฉพาะช่องเส้นทาง (ม่วงบนแผนที่) — วางโดมบนเส้นทาง · ไม่วาดเส้นทางใน Editor = เดินอิสระ · เปลี่ยนรูปพื้นหลังได้ทีหลัง'
: 'คำถามจาก Admin → Quiz Battle (battleQuizMcq) · ช่องโดมติดกัน = 1 ข้อ · ถูกแล้วได้ +1 ต่อโดม';
}
quizBattleAnsweredComps = new Set();
quizBattleModalCompId = null;
quizBattleMcqPool = [];
hideQuizBattleMcqModal();
playLiveQuizScores = {};
if (myId != null) playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
renderPlayQuizScoreboard(playLiveQuizScores);
loadQuizBattleMcqPool().then(() => {
const q2 = document.getElementById('quiz-game-question');
if (!q2) return;
if (!quizBattleMcqPool.length) {
q2.textContent = 'ยังไม่มีคำถาม — ไปที่ Admin แท็บ Quiz Battle แล้วบันทึกข้อ A/B/C (ครบคำถาม + A + B + C) · หรือเช็คว่าเซิร์ฟเวอร์เกม (Node) รันอยู่ · ลองรีเฟรชแบบ hard refresh (Ctrl+F5)';
} else {
const q0 = quizBattleMcqPool[0];
const snippet = q0 && q0.text ? String(q0.text).trim().slice(0, 100) : '';
const more = q0 && q0.text && String(q0.text).trim().length > 100 ? '…' : '';
q2.textContent = snippet
? ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ · ตัวอย่าง: 「' + snippet + more + '」\nเข้าโดมสีฟ้า (ขอบแดง) แล้วกด E เพื่อเลือก A / B / C')
: ('โหลดคำถามแล้ว ' + quizBattleMcqPool.length + ' ข้อ — เดินเข้าโดม (ฟ้า–ขอบแดง) แล้วกด E เพื่อตอบ A / B / C');
}
const ov2 = document.getElementById('quiz-game-overlay');
if (ov2 && previewMode && editorEmbedReturn) ov2.classList.add('is-hidden');
});
}
function normalizeJumpSurvivePlatformAreaInPlay(md) {
if (!md || md.gameType !== 'jump_survive') return;
const w = md.width || 20, h = md.height || 15;
const src = md.jumpSurvivePlatformArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
md.jumpSurvivePlatformArea = rows;
}
function normalizeShooterSpawnSlotsInPlay(md) {
if (!md || md.gameType !== 'space_shooter') return;
const w = md.width || 20, h = md.height || 15;
const src = md.shooterSpawnSlots || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
}
rows.push(row);
}
md.shooterSpawnSlots = rows;
}
function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
if (!md || !md.shooterSpawnSlots) return null;
const w = md.width || 20, h = md.height || 15;
const g = md.shooterSpawnSlots;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
}
}
return null;
}
function buildSpaceShooterParticipantRefsPlay() {
const refs = [];
if (me) refs.push({ id: myId, ref: me });
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
humanIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
botIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
return refs.filter((r) => r.ref);
}
function applySpaceShooterSpawnLayoutPlay() {
if (!mapData || mapData.gameType !== 'space_shooter') return;
normalizeShooterSpawnSlotsInPlay(mapData);
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const { cw, ch } = getCharacterFootprintWH(mapData);
const mw = w * ts, mh = h * ts;
const refs = buildSpaceShooterParticipantRefsPlay();
const n = refs.length || 1;
refs.forEach((entry, idx) => {
const ent = entry.ref;
const slot = (idx % 6) + 1;
const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
let cx, cy;
if (cen) {
cx = cen.cx;
cy = cen.cy;
} else {
cx = ((idx + 1) / (n + 1)) * mw;
cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
}
cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
cy = clampSpaceShooterWorldCy(cy, mh);
ent.spaceShooterCx = cx;
ent.spaceShooterCy = cy;
if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
ent.x = cx / ts - cw * 0.5;
ent.y = cy / ts - ch * 0.92;
ent.tx = ent.x;
ent.ty = ent.y;
ent.direction = 'up';
});
}
function spaceShooterTimeLimitSecPlay() {
const t = Number(mapData && mapData.spaceShooterTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const g = Number(playSpaceShooterMissionTimeSec);
if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
return 90;
}
function spaceShooterRemainingSecPlay() {
const lim = spaceShooterTimeLimitSecPlay();
if (lim <= 0) return null;
const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function endSpaceShooterTimeUp() {
if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
spaceShooterGameEnded = true;
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterSpawnAccMs = 0;
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
}
others.forEach((o, id) => {
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'}${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
}
function syncSpaceShooterFootFromShipCenter(ent) {
if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
ent.x = ent.spaceShooterCx / ts - cw * 0.5;
ent.y = ent.spaceShooterCy / ts - ch * 0.92;
}
/** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
function clampSpaceShooterWorldCy(cy, mhPx) {
if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
const edge = 20;
const lo = mhPx * 0.5;
const hi = Math.max(lo + 4, mhPx - edge);
return Math.max(lo, Math.min(hi, cy));
}
function stepSpaceShooterPreviewBots(dt) {
if (spaceShooterGameEnded || !previewFillBots || !mapData || mapData.gameType !== 'space_shooter') return;
const ts = tileSize;
const mw = (mapData.width || 20) * ts;
const mh = (mapData.height || 15) * ts;
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o || o.spaceShooterCx == null) return;
let nearest = null, nd = 1e9;
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
const a = spaceShooterAsteroids[i];
if (!a) continue;
const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
if (d < nd) { nd = d; nearest = a; }
}
let vx = 0;
if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
let vy = 0;
if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
vy = nearest.y > o.spaceShooterCy ? 1 : -1;
} else if (!nearest) {
vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
}
const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
o.spaceShooterCx = Math.max(18, Math.min(mw - 18, o.spaceShooterCx + vx * spd * dt));
o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
syncSpaceShooterFootFromShipCenter(o);
o.tx = o.x;
o.ty = o.y;
if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
o.spaceShooterBotFireCd -= dt;
if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
}
});
}
function spaceShooterTickFrame() {
if (!mapData || mapData.gameType !== 'space_shooter') return;
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const mw = w * ts, mh = h * ts;
const { cw, ch } = getCharacterFootprintWH(mapData);
const now = performance.now();
const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
spaceShooterLastTickMs = now;
if (spaceShooterGameEnded) {
const wallClock = Date.now();
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
}
return;
}
const remSec = spaceShooterRemainingSecPlay();
if (remSec != null && remSec <= 0) {
endSpaceShooterTimeUp();
return;
}
const interval = Math.max(320, Number(mapData.spaceShooterAsteroidIntervalMs) || 1040);
spaceShooterSpawnAccMs += dt * 1000;
while (spaceShooterSpawnAccMs >= interval) {
spaceShooterSpawnAccMs -= interval;
spaceShooterAsteroids.push({
x: 36 + Math.random() * (mw - 72),
y: -45 - Math.random() * 90,
r: 13 + Math.random() * 17,
vy: 58 + Math.random() * 62,
hp: 1,
});
}
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
o.spaceShooterCx = (o.x + cw * 0.5) * ts;
o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
});
let vx = 0;
let vy = 0;
if (!isChatFocused()) {
if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
}
const moveSpd = 280;
if (me.spaceShooterCx != null) {
me.spaceShooterCx = Math.max(18, Math.min(mw - 18, me.spaceShooterCx + vx * moveSpd * dt));
}
if (me.spaceShooterCy != null) {
me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
}
syncSpaceShooterFootFromShipCenter(me);
spaceShooterFireCd -= dt;
const fireHeld = !isChatFocused() && !!keys['Space'];
if (fireHeld && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
spaceShooterFireCd = 0.21;
spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
}
stepSpaceShooterPreviewBots(dt);
for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
const b = spaceShooterBullets[i];
b.y += b.vy * dt;
if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
}
for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
const a = spaceShooterAsteroids[ai];
a.y += a.vy * dt;
if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
}
for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
const b = spaceShooterBullets[bi];
let hit = -1;
for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
const a = spaceShooterAsteroids[ai];
const dx = b.x - a.x, dy = b.y - a.y;
const rr = (a.r + 7) * (a.r + 7);
if (dx * dx + dy * dy <= rr) { hit = ai; break; }
}
if (hit >= 0) {
const a = spaceShooterAsteroids[hit];
a.hp = (a.hp || 1) - 1;
spaceShooterBullets.splice(bi, 1);
if (a.hp <= 0) {
spaceShooterAsteroids.splice(hit, 1);
const add = 5;
if (b.ownerId === myId) {
me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
} else {
const o = others.get(b.ownerId);
if (o) {
o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
}
}
}
}
}
const wallClock = Date.now();
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
/* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
}
me.direction = 'up';
me.isWalking = vx !== 0 || vy !== 0;
const tEmit = Date.now();
if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
spaceShooterLastMoveEmit = tEmit;
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
});
}
}
function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
if (!mapData || !isSpaceShooter()) return;
const refs = buildSpaceShooterParticipantRefsPlay();
const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
const a = spaceShooterAsteroids[i];
const [sx, sy] = worldToScreen(a.x, a.y);
const sr = Math.max(6, a.r * zDraw);
const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
ctx.beginPath();
ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
ctx.stroke();
ctx.beginPath();
ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
ctx.shadowBlur = sr * 0.45;
ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
ctx.beginPath();
ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
}
for (let i = 0; i < spaceShooterBullets.length; i++) {
const b = spaceShooterBullets[i];
for (let k = 0; k < 4; k++) {
const t = k / 4;
const yy = b.y + t * 22;
const [bx, by] = worldToScreen(b.x, yy);
ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
ctx.beginPath();
ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
refs.forEach((entry, idx) => {
const ent = entry.ref;
if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
const bodyW = 14 * zDraw;
const bodyH = 22 * zDraw;
ctx.save();
ctx.translate(sx, sy);
ctx.fillStyle = col;
ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
ctx.lineWidth = Math.max(1.2, zDraw);
ctx.beginPath();
ctx.moveTo(0, -bodyH * 0.55);
ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
ctx.lineTo(0, bodyH * 0.22);
ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
ctx.beginPath();
ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
ctx.lineTo(0, bodyH * 0.72);
ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
ctx.closePath();
ctx.fill();
ctx.restore();
const name = (ent.nickname || '').slice(0, 10);
if (name) {
ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
ctx.lineWidth = 3;
ctx.strokeText(name, sx, sy - bodyH * 0.62);
ctx.fillText(name, sx, sy - bodyH * 0.62);
ctx.textAlign = 'left';
}
});
for (let i = 0; i < spaceShooterPopups.length; i++) {
const p = spaceShooterPopups[i];
if (wallNow >= p.until) continue;
const [px, py] = worldToScreen(p.x, p.y);
const dur = 700;
const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
ctx.save();
ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe066';
ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
ctx.lineWidth = 3;
ctx.strokeText(p.text || '+5', px, py - age * 18);
ctx.fillText(p.text || '+5', px, py - age * 18);
ctx.restore();
}
}
const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
function normalizeBalloonBossPlayerSlotsInPlay(md) {
if (!md || md.gameType !== 'balloon_boss') return;
const w = md.width || 20, h = md.height || 15;
const src = md.balloonBossPlayerSlots || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
}
rows.push(row);
}
md.balloonBossPlayerSlots = rows;
}
function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
if (!md || !md.balloonBossPlayerSlots) return null;
const w = md.width || 20, h = md.height || 15;
const g = md.balloonBossPlayerSlots;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
}
}
return null;
}
function getBalloonBossBossWorldCenterPlay(md, ts) {
const bx = md && md.balloonBossBossSpawn;
if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
}
const w = md.width || 20, h = md.height || 15;
return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
}
function buildBalloonBossParticipantRefsPlay() {
const refs = [];
if (me) refs.push({ id: myId, ref: me });
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
humanIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
botIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
return refs.filter((r) => r.ref);
}
function balloonBossBalloonsStartPlay() {
const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 5));
}
function balloonBossMaxHpPlay() {
const n = Number(mapData && mapData.balloonBossMaxHp);
return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
}
function balloonBossFireDelayMsPlay() {
const n = Number(mapData && mapData.balloonBossFireDelayMs);
return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
}
/** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
function balloonBossShipAccelPlay() {
const n = Number(mapData && mapData.balloonBossShipAccel);
return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
}
/** ความเร็วสูงสุดยาน (px/s) */
function balloonBossShipMaxSpeedPlay() {
const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
}
/** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
function balloonBossShipDragPlay() {
const n = Number(mapData && mapData.balloonBossShipDrag);
return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
}
function balloonBossTimeLimitSecPlay() {
const t = Number(mapData && mapData.balloonBossTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
return 120;
}
function balloonBossRemainingSecPlay() {
const lim = balloonBossTimeLimitSecPlay();
if (lim <= 0) return null;
const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function balloonBossTeamDamagePlay() {
let s = Math.max(0, me.balloonBossScore | 0);
others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
return s;
}
function syncBalloonBossFootFromCenter(ent) {
if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
ent.x = ent.balloonBossCx / ts - cw * 0.5;
ent.y = ent.balloonBossCy / ts - ch * 0.92;
}
function clampBalloonBossWorld(cx, cy, mw, mh) {
const e = 22;
return {
cx: Math.max(e, Math.min(mw - e, cx)),
cy: Math.max(e, Math.min(mh - e, cy)),
};
}
function applyBalloonBossSpawnLayoutPlay() {
if (!mapData || mapData.gameType !== 'balloon_boss') return;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const { cw, ch } = getCharacterFootprintWH(mapData);
const mw = w * ts, mh = h * ts;
const refs = buildBalloonBossParticipantRefsPlay();
const n = refs.length || 1;
const bStart = balloonBossBalloonsStartPlay();
refs.forEach((entry, idx) => {
const ent = entry.ref;
const slot = (idx % 6) + 1;
const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
let cx, cy;
if (cen) {
cx = cen.cx;
cy = cen.cy;
} else {
const t = (idx + 1) / (n + 1);
cx = t * mw;
cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
}
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
ent.balloonBossCx = cl.cx;
ent.balloonBossCy = cl.cy;
if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
if (typeof ent.balloonBossBalloons !== 'number' || !Number.isFinite(ent.balloonBossBalloons)) ent.balloonBossBalloons = bStart;
if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
ent.balloonBossVelX = 0;
ent.balloonBossVelY = 0;
ent.x = ent.balloonBossCx / ts - cw * 0.5;
ent.y = ent.balloonBossCy / ts - ch * 0.92;
ent.tx = ent.x;
ent.ty = ent.y;
ent.direction = 'up';
});
}
function endBalloonBossGame(reason) {
if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
balloonBossGameEnded = true;
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) {
titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
}
msgEl.textContent = reason === 'victory'
? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากความเสียหายที่ทำกับบอส'
: 'หมดเวลา — อันดับจากความเสียหายที่ทำกับบอส';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
}
others.forEach((o, id) => {
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — DMG ${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
}
function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
if (!previewFillBots || !mapData || mapData.gameType !== 'balloon_boss') return;
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
const spd = 48;
let nx = o.balloonBossCx + wob * spd * dt;
let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
const cl = clampBalloonBossWorld(nx, ny, mw, mh);
o.balloonBossCx = cl.cx;
o.balloonBossCy = cl.cy;
syncBalloonBossFootFromCenter(o);
o.tx = o.x;
o.ty = o.y;
if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
if (o.balloonBossBotNextFire <= 0) {
o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
const delayMs = balloonBossFireDelayMsPlay();
const dx = bossCx - o.balloonBossCx, dy = bossCy - o.balloonBossCy;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
balloonBossPendingShots.push({
releaseAt: performance.now() + delayMs,
sx: o.balloonBossCx, sy: o.balloonBossCy,
vx: (dx / d) * 520, vy: (dy / d) * 520,
ownerId: bid,
});
}
});
}
function balloonBossTickFrame() {
if (!mapData || mapData.gameType !== 'balloon_boss') return;
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const mw = w * ts, mh = h * ts;
const { cw, ch } = getCharacterFootprintWH(mapData);
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
if (!o || o.balloonBossEliminated) return;
if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
const cx = (o.x + cw * 0.5) * ts;
const cy = (o.y + ch * 0.92) * ts;
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
o.balloonBossCx = cl.cx;
o.balloonBossCy = cl.cy;
});
const now = performance.now();
const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
balloonBossLastTickMs = now;
const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts);
const bossR = Math.max(36, ts * 1.15);
const maxHp = balloonBossMaxHpPlay();
const teamDmg = balloonBossTeamDamagePlay();
if (me.balloonBossEliminated) {
me.balloonBossVelX = 0;
me.balloonBossVelY = 0;
}
if (balloonBossGameEnded) {
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
balloonBossHitFx[hi].t -= dt;
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
}
return;
}
const remSec = balloonBossRemainingSecPlay();
if (remSec != null && remSec <= 0) {
endBalloonBossGame('time');
return;
}
if (teamDmg >= maxHp) {
endBalloonBossGame('victory');
return;
}
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
balloonBossHitFx[hi].t -= dt;
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
}
const aliveRefs = buildBalloonBossParticipantRefsPlay().filter((e) => e.ref && !e.ref.balloonBossEliminated);
for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
const pnd = balloonBossPendingShots[pi];
if (now >= pnd.releaseAt) {
balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
balloonBossPendingShots.splice(pi, 1);
}
}
const bossFireEvery = 0.92;
balloonBossBossFireAcc += dt;
while (balloonBossBossFireAcc >= bossFireEvery) {
balloonBossBossFireAcc -= bossFireEvery;
if (aliveRefs.length) {
const wave = Math.floor(now / 1000);
const tgt = aliveRefs[wave % aliveRefs.length].ref;
if (tgt && tgt.balloonBossCx != null) {
const dx = tgt.balloonBossCx - bossC.cx, dy = tgt.balloonBossCy - bossC.cy;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
const spd = 290;
balloonBossBossBullets.push({ x: bossC.cx, y: bossC.cy, vx: (dx / d) * spd, vy: (dy / d) * spd });
}
}
}
function applyBalloonBossHitToEntity(ent) {
if (!ent || ent.balloonBossEliminated) return;
if (ent.balloonBossHitIframe > now) return;
ent.balloonBossHitIframe = now + 520;
ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
if (ent.balloonBossBalloons <= 0) {
ent.balloonBossEliminated = true;
ent.balloonBossBalloons = 0;
}
if (ent === me && socket) {
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
balloonBossBalloons: me.balloonBossBalloons,
balloonBossEliminated: !!me.balloonBossEliminated,
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
});
}
}
for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
const b = balloonBossBossBullets[bi];
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
balloonBossBossBullets.splice(bi, 1);
continue;
}
let hit = false;
aliveRefs.forEach((entry) => {
if (hit) return;
const ent = entry.ref;
if (!ent || ent.balloonBossCx == null) return;
const dx = b.x - ent.balloonBossCx, dy = b.y - ent.balloonBossCy;
if (dx * dx + dy * dy <= (26 * 26)) {
hit = true;
applyBalloonBossHitToEntity(ent);
}
});
if (hit) balloonBossBossBullets.splice(bi, 1);
}
for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
const b = balloonBossPlayerBullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
balloonBossPlayerBullets.splice(i, 1);
continue;
}
const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
if (dx * dx + dy * dy <= bossR * bossR) {
balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
const add = 1;
if (b.ownerId === myId) {
me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + add);
} else {
const o = others.get(b.ownerId);
if (o) o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + add);
}
balloonBossPlayerBullets.splice(i, 1);
}
}
let inX = 0, inY = 0;
if (!me.balloonBossEliminated && !isChatFocused()) {
if (keys['ArrowLeft'] || keys['KeyA']) inX -= 1;
if (keys['ArrowRight'] || keys['KeyD']) inX += 1;
if (keys['ArrowUp'] || keys['KeyW']) inY -= 1;
if (keys['ArrowDown'] || keys['KeyS']) inY += 1;
}
const accel = balloonBossShipAccelPlay();
const maxSpd = balloonBossShipMaxSpeedPlay();
const dragPerSec = balloonBossShipDragPlay();
if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
let nvx = me.balloonBossVelX;
let nvy = me.balloonBossVelY;
if (inX !== 0 || inY !== 0) {
const il = Math.sqrt(inX * inX + inY * inY) || 1;
nvx += (inX / il) * accel * dt;
nvy += (inY / il) * accel * dt;
}
const drag = Math.min(1, dragPerSec * dt);
nvx *= (1 - drag);
nvy *= (1 - drag);
const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
if (curSpd > maxSpd) {
const s = maxSpd / curSpd;
nvx *= s;
nvy *= s;
}
me.balloonBossVelX = nvx;
me.balloonBossVelY = nvy;
if (!me.balloonBossEliminated && me.balloonBossCx != null) {
const ncx = me.balloonBossCx + nvx * dt;
const ncy = me.balloonBossCy + nvy * dt;
const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX = 0;
if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY = 0;
me.balloonBossCx = cl.cx;
me.balloonBossCy = cl.cy;
}
syncBalloonBossFootFromCenter(me);
me.direction = 'up';
me.isWalking = Math.abs(me.balloonBossVelX) > 8 || Math.abs(me.balloonBossVelY) > 8 || inX !== 0 || inY !== 0;
balloonBossPlayerFireCd -= dt;
const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
balloonBossPlayerFireCd = 0.35;
const delayMs = balloonBossFireDelayMsPlay();
const dx = bossC.cx - me.balloonBossCx, dy = bossC.cy - me.balloonBossCy;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
balloonBossPendingShots.push({
releaseAt: now + delayMs,
sx: me.balloonBossCx, sy: me.balloonBossCy,
vx: (dx / d) * 480, vy: (dy / d) * 480,
ownerId: myId,
});
}
stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
const tEmit = Date.now();
if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
balloonBossLastMoveEmit = tEmit;
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
balloonBossEliminated: !!me.balloonBossEliminated,
});
}
}
function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
if (!mapData || !isBalloonBoss()) return;
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const mw = w * ts, mh = h * ts;
const bossC = getBalloonBossBossWorldCenterPlay(mapData, ts);
const maxHp = balloonBossMaxHpPlay();
const dmg = balloonBossTeamDamagePlay();
const hpLeft = Math.max(0, maxHp - dmg);
const bossR = Math.max(36, ts * 1.15);
const refs = buildBalloonBossParticipantRefsPlay();
const leaderId = (() => {
let best = null, bs = -1;
refs.forEach((e) => {
const sc = Math.max(0, e.ref.balloonBossScore | 0);
if (sc > bs) { bs = sc; best = e.id; }
});
return best;
})();
for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
const fx = balloonBossHitFx[hi];
const [hx, hy] = worldToScreen(fx.x, fx.y);
const pulse = 1 + (fx.t || 0) * 3;
ctx.save();
ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
ctx.lineWidth = 3 * zDraw;
ctx.beginPath();
ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
ctx.save();
ctx.translate(bsx, bsy);
ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)';
ctx.lineWidth = Math.max(2, 2.5 * zDraw);
ctx.beginPath();
for (let k = 0; k < 6; k++) {
const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(ang) * bossR * zDraw * 1.05;
const py = Math.sin(ang) * bossR * zDraw * 1.05;
if (k === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
ctx.fill();
const skullR = bossR * zDraw * 0.62;
const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(0, 0, skullR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
ctx.shadowBlur = 12 * zDraw;
ctx.beginPath();
ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
ctx.font = `bold ${Math.max(10, 11 * zDraw)}px ui-monospace, monospace`;
ctx.textAlign = 'center';
ctx.fillText('MEGA VIRUS : [ ' + hpLeft + ' / ' + maxHp + ' ]', 0, -bossR * zDraw - 14);
const barW = bossR * zDraw * 2.2;
const barH = Math.max(5, 6 * zDraw);
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
ctx.fillRect(-barW / 2, -bossR * zDraw - 8, barW * (hpLeft / maxHp), barH);
ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
ctx.strokeRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
ctx.restore();
function drawBalloonsCluster(sx, sy, count, col, z) {
const n = Math.max(0, Math.min(8, count | 0));
for (let b = 0; b < n; b++) {
const ox = (b - (n - 1) / 2) * 7 * z;
const oy = -18 * z - Math.abs(b - (n - 1) / 2) * 2 * z;
ctx.fillStyle = col;
ctx.beginPath();
ctx.ellipse(sx + ox, sy + oy, 6.5 * z, 8 * z, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
refs.forEach((entry, idx) => {
const ent = entry.ref;
if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
const z = zDraw;
ctx.save();
if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)';
ctx.lineWidth = Math.max(1.2, 1.5 * z);
ctx.beginPath();
ctx.arc(sx, sy, 22 * z, 0, Math.PI * 2);
ctx.stroke();
drawBalloonsCluster(sx, sy, ent.balloonBossBalloons | 0, col, z);
const nm = String(ent.nickname || '').slice(0, 12);
ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
ctx.lineWidth = 3;
let label = nm;
if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
ctx.strokeText(label, sx, sy + 28 * z);
ctx.fillText(label, sx, sy + 28 * z);
const charImg = getPlayTintedAvatarSource(
getAvatarImg(ent.characterId, 'up', timeMs, !!ent.isWalking),
ent.characterId, 'up', timeMs, !!ent.isWalking,
ent.playTint || playTintFromPeerId(entry.id)
);
const size = 26 * z;
if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
const sc = Math.min(size / iw, size / ih, 1);
const dw = iw * sc, dh = ih * sc;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, sy - dh + 4 * z, dw, dh);
} else {
ctx.fillStyle = col;
ctx.fillRect(sx - 10 * z, sy - 18 * z, 20 * z, 22 * z);
}
ctx.restore();
});
for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
const b = balloonBossPlayerBullets[i];
const [bx, by] = worldToScreen(b.x, b.y);
ctx.save();
ctx.translate(bx, by);
ctx.rotate(Math.atan2(b.vy, b.vx) + Math.PI / 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.beginPath();
ctx.moveTo(0, -8 * zDraw);
ctx.lineTo(-5 * zDraw, 6 * zDraw);
ctx.lineTo(5 * zDraw, 6 * zDraw);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
ctx.textAlign = 'center';
ctx.fillText('01001', 0, 12 * zDraw);
ctx.restore();
}
for (let i = 0; i < balloonBossBossBullets.length; i++) {
const b = balloonBossBossBullets[i];
const [bx, by] = worldToScreen(b.x, b.y);
ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
ctx.beginPath();
ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
ctx.stroke();
}
}
function jumpSurviveCollectPlatformCells(md) {
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
const out = [];
if (!pa) return out;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (pa[y] && pa[y][x] === 1) out.push({ x, y });
}
}
return out;
}
/** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกบนจะวนมาโผล่ล่าง */
function jumpSurvivePlatformPeriodPx(md) {
return (md.height || 15) * tileSize;
}
/**
* ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
*/
function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
const ts = tileSize;
const h = md.height || 15;
const period = jumpSurvivePlatformPeriodPx(md);
const { cw, ch } = getCharacterFootprintWH(md);
const rawTop = ty * ts - scrollPx;
const hintY = (jumpSurviveCamCenterY > 1)
? jumpSurviveCamCenterY
: h * ts * 0.72;
const k = Math.round((hintY - rawTop - ts * 0.35) / period);
const platTop = rawTop + k * period;
const feetY = platTop;
const pxc = (tx + 0.5) * ts;
return {
x: pxc / ts - cw * 0.5,
y: feetY / ts - ch,
};
}
function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
}
function jumpSurviveRandomStandGridPos(md) {
const cells = jumpSurviveCollectPlatformCells(md);
const sc = jumpSurvivePlatformScrollPx;
if (cells.length) {
const c = cells[Math.floor(Math.random() * cells.length)];
return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
}
const sp = md.spawn || { x: 1, y: 1 };
return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
}
/** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
if (!mapData || mapData.gameType !== 'jump_survive') return;
const cells = jumpSurviveCollectPlatformCells(mapData);
cells.sort((a, b) => b.y - a.y || a.x - b.x);
const sc = jumpSurvivePlatformScrollPx;
const pool = cells.length
? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
: [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
function stampJumpBotState(o) {
if (!o) return;
o.jumpSurviveVy = 0;
o.jumpSurviveOnGround = true;
o.jumpSurviveEliminated = false;
o.tx = o.x;
o.ty = o.y;
}
if (onlyBots) {
let idx = 1 + realIds.length;
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || !pool.length) return;
const p = pool[idx % pool.length];
idx++;
o.x = p.x + (Math.random() - 0.5) * 0.08;
o.y = p.y + (Math.random() - 0.5) * 0.08;
stampJumpBotState(o);
});
return;
}
let idx = 0;
function assignEnt(ent) {
if (!ent || !pool.length) return;
const p = pool[idx % pool.length];
idx++;
ent.x = p.x + (Math.random() - 0.5) * 0.08;
ent.y = p.y + (Math.random() - 0.5) * 0.08;
stampJumpBotState(ent);
}
assignEnt(me);
realIds.forEach((rid) => assignEnt(others.get(rid)));
botIds.forEach((bid) => assignEnt(others.get(bid)));
jumpSurviveInitRuntime();
}
/**
* ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
* @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
*/
function jumpSurvivePhysicsIntegrate(md, opts) {
const {
dt,
cw, ch,
pxc0, feetY0, vy0, wasOnGround,
jumpQueued,
moveLeft, moveRight,
camCenterY,
halfViewH,
} = opts;
const w = md.width || 20, h = md.height || 15;
const ts = tileSize;
const bw = ts * 0.34;
const bh = ts * 0.9;
let pxc = pxc0;
let feetY = feetY0;
let vy = vy0;
let onGround = wasOnGround;
const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
const g = gFull * dt;
let jumpV0;
if (Number(md.jumpSurviveJumpImpulse) > 0) {
jumpV0 = -Number(md.jumpSurviveJumpImpulse);
} else {
const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
? playJumpSurviveJumpHeightMult
: 1.5;
const charHpixels = ch * ts;
const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
jumpV0 = -Math.min(impulse, ts * 36);
}
let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35;
if (jumpQueued && onGround) {
vy = jumpV0;
onGround = false;
}
vy += g;
let nx = pxc;
if (moveLeft) nx -= moveX;
if (moveRight) nx += moveX;
nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
const headY0 = feetY - bh;
const tryL = nx - bw;
const tryR = nx + bw;
if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) {
pxc = nx;
}
feetY += vy * dt;
let headTop = feetY - bh;
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
if (vy > 0) {
for (let s = 0; s < 22; s++) {
feetY -= ts * 0.035;
headTop = feetY - bh;
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
vy = 0;
onGround = true;
break;
}
}
} else if (vy < 0) {
for (let s = 0; s < 22; s++) {
feetY += ts * 0.035;
headTop = feetY - bh;
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
vy = 0;
break;
}
}
}
} else {
onGround = false;
}
headTop = feetY - bh;
const headBandBottom = headTop + bh * 0.42;
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
return { pxc, feetY, vy, onGround, died: true };
}
headTop = feetY - bh;
const topKill = camCenterY - halfViewH - ts * 0.35;
const botKill = camCenterY + halfViewH + ts * 0.85;
if (feetY < topKill || feetY > botKill || headTop < -ts * 2) {
return { pxc, feetY, vy, onGround, died: true };
}
return { pxc, feetY, vy, onGround, died: false };
}
function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
const { cw, ch } = getCharacterFootprintWH(md);
o.x = pxc / tileSize - cw * 0.5;
o.y = feetYpx / tileSize - ch;
}
function jumpSurviveInitRuntime() {
jumpSurviveLastTickMs = performance.now();
jumpSurviveSessionStartMs = performance.now();
jumpSurviveVy = 0;
jumpSurviveJumpQueued = false;
jumpSurviveOnGround = false;
jumpSurvivePlatformScrollPx = 0;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
if (isJumpSurviveMissionUiMapPlay()) {
const w = mapData.width || 20, h = mapData.height || 15;
jumpSurviveCamCenterX = (w * ts) * 0.5;
jumpSurviveCamCenterY = (h * ts) * 0.5;
} else {
jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
}
}
function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts);
const x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts);
const y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w || ty >= h) return true;
if (ty < 0) continue;
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
if (!pa) return false;
const period = jumpSurvivePlatformPeriodPx(md);
if (period < ts) return false;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const vm = ts * 2;
for (let ty = 0; ty < h; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!pa[ty] || pa[ty][tx] !== 1) continue;
const rawTop = ty * ts - scrollPx;
const kMin = Math.ceil((top - rawTop - ts - vm) / period);
const kMax = Math.floor((bottom - rawTop + vm) / period);
for (let k = kMin; k <= kMax; k++) {
const platTop = rawTop + k * period;
const platBot = platTop + ts;
const tileLeft = tx * ts;
const tileRight = (tx + 1) * ts;
if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
}
}
}
return false;
}
function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
}
/**
* ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย
* (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน)
*/
function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) {
if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
const ts = tileSize;
const footSlop = ts * 0.16;
const platformBottom = bottom - footSlop;
if (platformBottom <= top + ts * 0.05) return false;
return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx);
}
/** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts);
const x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts);
const y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
}
function jumpSurviveCameraTargetPx(md) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
if (isJumpSurviveMissionUiMapPlay()) {
return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 };
}
const { cw, ch } = getCharacterFootprintWH(md);
const cx = (e) => (e.x + cw * 0.5) * ts;
const cy = (e) => (e.y + ch * 0.5) * ts;
if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
const aliveHumanIds = [...others.keys()].filter((id) => {
if (isPreviewBotId(id)) return false;
const o = others.get(id);
return o && !o.jumpSurviveEliminated;
});
const aliveBotIds = [...others.keys()].filter((id) => {
if (!isPreviewBotId(id)) return false;
const o = others.get(id);
return o && !o.jumpSurviveEliminated;
});
const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
if (!useIds.length) return { px: cx(me), py: cy(me) };
let sx = 0, sy = 0;
useIds.forEach((id) => {
const o = others.get(id);
if (!o) return;
sx += cx(o);
sy += cy(o);
});
const n = useIds.length;
return { px: sx / n, py: sy / n };
}
/** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */
function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) {
if (!wishDir) return true;
const ts = tileSize;
const bw = ts * 0.34;
const probeX = pxc + wishDir * (bw + ts * 0.82);
const pad = ts * 0.24;
const left = probeX - pad;
const right = probeX + pad;
const top = feetY - ts * 0.45;
const bottom = feetY + ts * 8;
return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx);
}
function stepJumpSurvivePreviewBots(dt, halfViewH) {
if (!previewFillBots || !mapData || !isJumpSurvive()) return;
const md = mapData;
const { cw, ch } = getCharacterFootprintWH(md);
const ts = tileSize;
const wpx = (md.width || 20) * ts;
const centerPx = wpx * 0.5;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
if (o.jumpSurviveEliminated) return;
if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
let pxc = (o.x + cw * 0.5) * ts;
let feetY = (o.y + ch) * ts;
const camCy = jumpSurviveCamCenterY;
const mission = isJumpSurviveMissionUiMapPlay();
const lowDanger = feetY > camCy + halfViewH - ts * 8.5;
if (Date.now() >= o.jumpSurviveWishNext) {
const wMin = mission && lowDanger ? 180 : 380;
const wSpan = mission && lowDanger ? 480 : 700;
o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan);
const tier = o.botTier || 'avg';
if (mission && lowDanger && Math.random() < 0.55) {
o.jumpSurviveWishX = 0;
} else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
else if (tier === 'sharp') {
const roam = mission ? 0.42 : 0.55;
o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
} else {
const roam = mission ? 0.28 : 0.4;
o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
}
}
let moveLeft = o.jumpSurviveWishX < 0;
let moveRight = o.jumpSurviveWishX > 0;
let hazardJump = false;
if (o.jumpSurviveOnGround) {
const sc = jumpSurvivePlatformScrollPx;
if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) {
o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0;
hazardJump = true;
} else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) {
o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0;
hazardJump = true;
}
moveLeft = o.jumpSurviveWishX < 0;
moveRight = o.jumpSurviveWishX > 0;
}
let jumpQ = false;
if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now());
if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
const tier = o.botTier || 'avg';
let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
if (mission) jp += 0.12;
if (lowDanger) jp += 0.22;
jp = Math.min(0.9, jp);
if (hazardJump || Math.random() < jp) jumpQ = true;
}
const r = jumpSurvivePhysicsIntegrate(md, {
dt,
cw,
ch,
pxc0: pxc,
feetY0: feetY,
vy0: o.jumpSurviveVy,
wasOnGround: o.jumpSurviveOnGround,
jumpQueued: jumpQ,
moveLeft,
moveRight,
camCenterY: jumpSurviveCamCenterY,
halfViewH,
});
if (r.died) {
o.jumpSurviveEliminated = true;
o.jumpSurviveVy = 0;
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
o.tx = o.x;
o.ty = o.y;
o.botIsWalking = false;
return;
}
o.jumpSurviveVy = r.vy;
o.jumpSurviveOnGround = r.onGround;
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
o.tx = o.x;
o.ty = o.y;
if (moveRight) o.direction = 'right';
else if (moveLeft) o.direction = 'left';
o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
});
}
function jumpSurviveTickFrame() {
if (!mapData || !isJumpSurvive()) return;
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
const remEnd = jumpSurviveRemainingSecMission();
if (remEnd !== null && remEnd <= 0) {
endJumpSurviveMissionRound();
return;
}
}
const md = mapData;
const ts = tileSize;
const now = performance.now();
const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
jumpSurviveLastTickMs = now;
const halfViewH = canvas.height / (2 * zoom);
const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
jumpSurvivePlatformScrollPx += rise;
const camTgt = jumpSurviveCameraTargetPx(md);
const ck = Math.min(1, dt * 7);
jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
if (jumpSurviveEliminated) {
stepJumpSurvivePreviewBots(dt, halfViewH);
jumpSurviveMissionMaybeEarlyFinish();
return;
}
const { cw, ch } = getCharacterFootprintWH(md);
let pxc = (me.x + cw * 0.5) * ts;
let feetY = (me.y + ch) * ts;
const jq = jumpSurviveJumpQueued;
jumpSurviveJumpQueued = false;
const r = jumpSurvivePhysicsIntegrate(md, {
dt,
cw,
ch,
pxc0: pxc,
feetY0: feetY,
vy0: jumpSurviveVy,
wasOnGround: jumpSurviveOnGround,
jumpQueued: jq,
moveLeft: !!(keys['ArrowLeft'] || keys['KeyA']),
moveRight: !!(keys['ArrowRight'] || keys['KeyD']),
camCenterY: jumpSurviveCamCenterY,
halfViewH,
});
if (r.died) {
jumpSurviveEliminated = true;
jumpSurviveVy = 0;
jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'KeyA', 'KeyW', 'KeyS', 'KeyD'].forEach((c) => { keys[c] = false; });
} else {
jumpSurviveVy = r.vy;
jumpSurviveOnGround = r.onGround;
jumpSurviveSyncMeFromHitbox(r.pxc, r.feetY);
}
stepJumpSurvivePreviewBots(dt, halfViewH);
if (!jumpSurviveEliminated) {
me.isWalking = Math.abs(jumpSurviveVy) > 40 || !!(keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']);
if (keys['ArrowRight'] || keys['KeyD']) me.direction = 'right';
else if (keys['ArrowLeft'] || keys['KeyA']) me.direction = 'left';
if (Date.now() - jumpSurviveLastEmitT > 80) {
jumpSurviveLastEmitT = Date.now();
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
}
jumpSurviveMissionMaybeEarlyFinish();
}
function gridImageLibIdleUrlPlay(entry) {
if (entry == null) return '';
if (typeof entry === 'string') return entry;
if (typeof entry === 'object' && entry.idle) return String(entry.idle);
return '';
}
function gridImageLibHeldUrlPlay(entry) {
if (entry == null || typeof entry === 'string') return '';
if (typeof entry === 'object' && entry.held && String(entry.held).length) return String(entry.held);
return '';
}
function normalizeGridImageCellsOnMap(md) {
if (!md) return;
const w = md.width || 20, h = md.height || 15;
if (!Array.isArray(md.gridImageLibrary)) md.gridImageLibrary = [];
md.gridImageLibrary = md.gridImageLibrary
.map((raw) => {
if (typeof raw === 'string' && raw.length > 0 && raw.length < 30000000) return { idle: raw, held: null };
if (raw && typeof raw === 'object' && typeof raw.idle === 'string' && raw.idle.length > 0 && raw.idle.length < 30000000) {
let held = typeof raw.held === 'string' && raw.held.length > 0 && raw.held.length < 30000000 ? raw.held : null;
if (held && held === raw.idle) held = null;
return { idle: raw.idle, held };
}
return null;
})
.filter(Boolean);
const libLen = md.gridImageLibrary.length;
let placements = [];
if (Array.isArray(md.gridImageSprites) && md.gridImageSprites.length) {
placements = md.gridImageSprites.map((raw) => {
const i = Math.max(0, Math.floor(Number(raw.i)));
const x = Math.floor(Number(raw.x));
const y = Math.floor(Number(raw.y));
const ww = Math.max(1, Math.floor(Number(raw.w)) || 1);
const hh = Math.max(1, Math.floor(Number(raw.h)) || 1);
const rawB = raw.bindCarryOption;
const bn = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
const base = { i, x, y, w: ww, h: hh };
if (Number.isFinite(bn) && bn >= 1 && bn <= QUIZ_CARRY_MAX_OPTION_SLOTS) base.bindCarryOption = bn;
return base;
}).filter((s) => Number.isFinite(s.i) && s.i >= 0 && s.i < libLen &&
Number.isFinite(s.x) && Number.isFinite(s.y) && s.w >= 1 && s.h >= 1 &&
s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
placements = placements.map((s) => {
let x = s.x, y = s.y, ww = s.w, hh = s.h;
if (x < 0) { ww += x; x = 0; }
if (y < 0) { hh += y; y = 0; }
if (x + ww > w) ww = w - x;
if (y + hh > h) hh = h - y;
const out = { i: s.i, x, y, w: Math.max(1, ww), h: Math.max(1, hh) };
if (s.bindCarryOption != null) out.bindCarryOption = s.bindCarryOption;
return out;
}).filter((s) => s.x < w && s.y < h && s.x + s.w > 0 && s.y + s.h > 0);
} else {
const cells = md.gridImageCells;
if (Array.isArray(cells) && cells.length === h && libLen > 0) {
for (let yy = 0; yy < h; yy++) {
const row = cells[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (row[xx] == null) continue;
const iv = parseInt(row[xx], 10);
if (!Number.isFinite(iv) || iv < 0 || iv >= libLen) continue;
placements.push({ i: iv, x: xx, y: yy, w: 1, h: 1 });
}
}
}
}
md.gridImagePlacements = placements;
const outCells = Array(h).fill(0).map(() => Array(w).fill(null));
placements.forEach((sp) => {
for (let yy = sp.y; yy < sp.y + sp.h; yy++) {
for (let xx = sp.x; xx < sp.x + sp.w; xx++) {
if (yy >= 0 && yy < h && xx >= 0 && xx < w) outCells[yy][xx] = sp.i;
}
}
});
md.gridImageCells = outCells;
}
function loadMapGridImages() {
mapGridImageImgs = [];
mapGridImageHeldImgs = [];
if (!mapData || !Array.isArray(mapData.gridImageLibrary) || !mapData.gridImageLibrary.length) return;
mapData.gridImageLibrary.forEach((entry, i) => {
mapGridImageImgs[i] = null;
mapGridImageHeldImgs[i] = null;
const idle = gridImageLibIdleUrlPlay(entry);
if (!idle) return;
const im = new Image();
im.onload = () => { try { draw(); } catch (e) {} };
im.onerror = () => {};
im.src = idle;
mapGridImageImgs[i] = im;
const held = gridImageLibHeldUrlPlay(entry);
if (held && held !== idle) {
const hm = new Image();
hm.onload = () => { try { draw(); } catch (e) {} };
hm.onerror = () => {};
hm.src = held;
mapGridImageHeldImgs[i] = hm;
}
});
}
/** โซนตัวเลือกที่ทับสไปรต์มากที่สุด — quiz_carry สลับรูป idle/held · ถ้าไม่ทับเลยลองขยายขอบ 1 ช่อง (มาร์กเกอร์ชิดขอบสไปรต์) */
function dominantQuizCarryOptionInSpriteRect(md, sp) {
if (!md || md.gameType !== 'quiz_carry' || !md.quizCarryOptionArea) return null;
const g = md.quizCarryOptionArea;
const mw = md.width || 20, mh = md.height || 15;
const bx = sp.x | 0, by = sp.y | 0, bw = sp.w | 0, bh = sp.h | 0;
const counts = new Map();
function addOverlapRect(x0, y0, ww, hh) {
for (let yy = y0; yy < y0 + hh; yy++) {
if (yy < 0 || yy >= mh) continue;
const row = g[yy];
if (!row) continue;
for (let xx = x0; xx < x0 + ww; xx++) {
if (xx < 0 || xx >= mw) continue;
const v = row[xx];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
if (!Number.isFinite(n) || n < 1 || n > QUIZ_CARRY_MAX_OPTION_SLOTS) continue;
const idx = n - 1;
counts.set(idx, (counts.get(idx) || 0) + 1);
}
}
}
addOverlapRect(bx, by, bw, bh);
if (!counts.size) {
const pad = 1;
const x0 = Math.max(0, bx - pad);
const y0 = Math.max(0, by - pad);
const x1 = Math.min(mw, bx + bw + pad);
const y1 = Math.min(mh, by + bh + pad);
addOverlapRect(x0, y0, x1 - x0, y1 - y0);
}
if (!counts.size) return null;
let best = null;
let bestN = -1;
counts.forEach((n, k) => {
if (best == null || n > bestN || (n === bestN && k < best)) {
bestN = n;
best = k;
}
});
return best;
}
/** 0-based choice index สำหรับสลับ idle/held — ใช้ bindCarryOption จากแมปก่อน แล้วค่อยนับทับโซน */
function quizCarryOptionIndexForGridSprite(md, sp) {
if (!sp) return null;
const rawB = sp.bindCarryOption;
if (rawB != null && rawB !== '') {
const n = typeof rawB === 'number' ? rawB : parseInt(String(rawB), 10);
if (Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS) return n - 1;
}
return dominantQuizCarryOptionInSpriteRect(md, sp);
}
/** รูป held จากคลังกริดสำหรับข้อนี้ — ใช้วาดป้ายติดตัว (ไม่ผ่าน sanitize URL) */
function findQuizCarryGridHeldImgForChoiceIndex(choiceIdx) {
if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
const pl = mapData.gridImagePlacements;
if (!Array.isArray(pl) || !pl.length) return null;
for (let gi = 0; gi < pl.length; gi++) {
const sp = pl[gi];
const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
if (opt !== choiceIdx) continue;
const gh = mapGridImageHeldImgs[sp.i];
if (gh && gh.complete && gh.naturalWidth) return gh;
}
return null;
}
/** รูป idle จากคลังกริดสำหรับข้อนี้ — วาดบนป้ายตัวเลือกบนพื้นเมื่อไม่มีรูปจากคำถาม/ธีม */
function findQuizCarryGridIdleImgForChoiceIndex(choiceIdx) {
if (choiceIdx == null || choiceIdx < 0 || !isQuizCarry() || !mapData) return null;
const pl = mapData.gridImagePlacements;
if (!Array.isArray(pl) || !pl.length) return null;
for (let gi = 0; gi < pl.length; gi++) {
const sp = pl[gi];
const opt = quizCarryOptionIndexForGridSprite(mapData, sp);
if (opt !== choiceIdx) continue;
const gid = mapGridImageImgs[sp.i];
if (gid && gid.complete && gid.naturalWidth) return gid;
}
return null;
}
function applyMapAndStart(gameMapData, res) {
const prevWasSpaceShooter = mapData && mapData.gameType === 'space_shooter';
const prevWasBalloonBoss = mapData && mapData.gameType === 'balloon_boss';
mapData = gameMapData;
if (res && res.mapId != null && String(res.mapId).trim() !== '') {
playSessionMapId = String(res.mapId).trim();
}
if (res && res.hostId != null) playHostId = res.hostId;
if (mapData.gameType !== 'space_shooter') {
spaceShooterGameEnded = false;
if (prevWasSpaceShooter) {
const gend = document.getElementById('gauntlet-ended-overlay');
if (gend) gend.classList.add('is-hidden');
}
}
if (mapData.gameType !== 'balloon_boss') {
balloonBossGameEnded = false;
if (prevWasBalloonBoss) {
const gend = document.getElementById('gauntlet-ended-overlay');
if (gend) gend.classList.add('is-hidden');
}
}
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
if (!mapData.interactive) mapData.interactive = [];
if (mapData.gameType === 'quiz') {
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
}
if (mapData.gameType === 'stack') {
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
if (!mapData.stackLandArea) mapData.stackLandArea = [];
}
if (mapData.gameType === 'quiz_carry') {
if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = [];
if (!mapData.quizQuestions) mapData.quizQuestions = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
normalizeQuizCarryLayersInPlay(mapData);
}
if (mapData.gameType === 'quiz_battle') {
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
normalizeQuizBattlePathInPlay(mapData);
normalizeQuizBattleDomeInPlay(mapData);
}
tileSize = mapData.tileSize || 32;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
const plist = Array.isArray(res.peers) ? res.peers : [];
const myPeer = plist.find(p => p.id === myId);
const myPos = peerXYFromJoin(myPeer, mapData.spawn);
me.x = myPos.x;
me.y = myPos.y;
if (isQuizCarry()) {
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
me.x = hubSnap.x;
me.y = hubSnap.y;
}
if (isQuizBattle()) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = pathSnap.x;
me.y = pathSnap.y;
}
me.direction = myPeer?.direction ?? 'down';
me.characterId = myPeer?.characterId || null;
meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
meGauntletJumpVis = meGauntletJumpTicks;
{
const sc0 = Number(myPeer && myPeer.gauntletScore);
me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated);
}
{
const sh0 = Number(myPeer && myPeer.spaceShooterScore);
me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
}
{
const bSt = balloonBossBalloonsStartPlay();
const bs0 = Number(myPeer && myPeer.balloonBossScore);
me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
}
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
me.playTint = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local')));
others.clear();
plist.forEach(p => {
if (p.id !== myId) {
const pos = peerXYFromJoin(p, mapData.spawn);
let px = pos.x, py = pos.y;
if (isQuizCarry()) {
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
px = hubSnap.x;
py = hubSnap.y;
}
if (isQuizBattle()) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
px = pathSnap.x;
py = pathSnap.y;
}
others.set(p.id, {
x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
playTint: playTintFromPeerId(p.id),
gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0,
gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
gauntletEliminated: !!p.gauntletEliminated,
jumpSurviveEliminated: false,
spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossBalloons: (() => {
const s = Number(p.balloonBossBalloons);
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
})(),
balloonBossEliminated: !!p.balloonBossEliminated,
quizCarryHeld: null,
});
}
});
if (mapData.gameType === 'gauntlet') {
me.tx = me.x;
me.ty = me.y;
if (res.gauntletEndsAt != null) {
const geJoin = Number(res.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
} else {
gauntletEndsAtMs = null;
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {});
} else {
me.tx = null;
me.ty = null;
gauntletEndsAtMs = null;
}
rebalancePreviewBots();
if (mapData.gameType === 'space_shooter') {
applySpaceShooterSpawnLayoutPlay();
}
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
if (isStack()) {
lastStackTickMs = performance.now();
resetStackMinigameState();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
else reapplyStackMiniSizingFromGlobals();
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
});
} else {
stackMini = null;
}
playPath = [];
me.isWalking = false;
if (mapData.gameType === 'jump_survive') {
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
normalizeJumpSurvivePlatformAreaInPlay(mapData);
jumpSurviveEliminated = false;
jumpSurviveGameEnded = false;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
if (isJumpSurviveMissionUiMapPlay()) {
jumpSurviveMissionPhase = 'howto';
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50);
} else {
jumpSurviveMissionPhase = null;
jumpSurviveInitRuntime();
}
}
if (mapData.gameType === 'space_shooter') {
spaceShooterGameEnded = false;
normalizeShooterSpawnSlotsInPlay(mapData);
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterPopups = [];
spaceShooterLastTickMs = performance.now();
spaceShooterSpawnAccMs = 0;
spaceShooterFireCd = 0;
spaceShooterSessionStartMs = performance.now();
spaceShooterLastMoveEmit = 0;
}
if (mapData.gameType === 'balloon_boss') {
balloonBossGameEnded = false;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossHitFx = [];
balloonBossLastTickMs = performance.now();
balloonBossPlayerFireCd = 0;
balloonBossSessionStartMs = performance.now();
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
}
if (isQuiz()) setupPlayQuizUi();
else if (isQuizCarry()) setupPlayQuizCarryUi();
else if (isQuizBattle()) setupPlayQuizBattleUi();
else teardownPlayQuizUi();
if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) {
hideConflictingOverlaysForGauntletCrown();
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccR = document.getElementById('gauntlet-crown-countdown');
if (gccR) gccR.classList.add('is-hidden');
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
} else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) {
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccRj = document.getElementById('gauntlet-crown-countdown');
if (gccRj) gccRj.classList.add('is-hidden');
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
} else {
const hov = document.getElementById('gauntlet-crown-howto-overlay');
if (hov) {
hov.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
}
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccR2 = document.getElementById('gauntlet-crown-countdown');
if (gccR2) gccR2.classList.add('is-hidden');
}
resizeCanvas(); draw(); tick();
}
socket.on('connect', () => {
firstCharacterDefaultPromise.then(() => {
socket.emit('join-space', { spaceId, nickname: nick, characterId: getPlayCharacterId() }, (res) => {
if (!res || !res.ok) {
const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
alert(errMsg);
const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
const detectiveReturn = params.get('detectiveReturn') === '1'
|| (function () { try { return sessionStorage.getItem('detectiveMinigameReturn') === '1'; } catch (e) { return false; } })();
if (caseLocked || detectiveReturn) {
window.location.replace(buildRoomLobbyReturnHref());
} else {
window.location.replace(BASE + '/lobby.html');
}
return;
}
myId = socket.id;
let botSlots = parseInt(res.botSlotCount, 10);
if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
botSlots = 6 - res.maxPlayers;
}
if (botSlots > 0) {
detectiveCaseFillBots = true;
detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots));
}
if (isDetectiveMinigamePlay()) {
try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ }
}
if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') {
quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap;
if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') {
setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme });
}
if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') {
setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme });
}
if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) {
setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes });
} else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') {
setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme });
}
const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale);
if (Number.isFinite(joinSc)) {
quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc));
}
}
if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
const q = [];
q.push('space=' + encodeURIComponent(spaceId));
q.push('nick=' + encodeURIComponent(nick));
const mid = params.get('map');
if (mid) q.push('map=' + encodeURIComponent(mid));
q.push('preview=1');
q.push('editorEmbed=1');
if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
return;
}
const playMapId = params.get('map');
if (playMapId) {
fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) applyMapAndStart(data, res);
else applyMapAndStart(res.mapData, res);
})
.catch(() => applyMapAndStart(res.mapData, res));
} else {
applyMapAndStart(res.mapData, res);
}
});
});
});
const LERP = 0.2;
socket.on('user-joined', (data) => {
if (!data || isPreviewBotId(data.id)) return;
const x = Number(data.x);
const y = Number(data.y);
const px = Number.isFinite(x) ? x : 1;
const py = Number.isFinite(y) ? y : 1;
others.set(data.id, {
x: px, y: py, tx: px, ty: py,
direction: data.direction || 'down', nickname: data.nickname,
characterId: data.characterId ?? null,
playTint: playTintFromPeerId(data.id),
gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0,
gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
jumpSurviveEliminated: false,
spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossBalloons: (() => {
const s = Number(data.balloonBossBalloons);
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
})(),
balloonBossEliminated: !!data.balloonBossEliminated,
quizCarryHeld: null,
});
if (previewFillBots) rebalancePreviewBots();
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
});
socket.on('host-changed', (d) => {
if (d && d.hostId != null) playHostId = d.hostId;
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
});
socket.on('quiz-carry-lobby-sync', (d) => {
if (!d || typeof d.readyMap !== 'object') return;
quizCarryLobbyReadyMap = { ...d.readyMap };
if (quizCarryPregameActive && isQuizCarry()) {
quizCarrySyncGuestReadyIfNeeded();
updateQuizCarryPregameHud();
}
});
socket.on('quiz-carry-lobby-started', () => {
if (!(previewMode && editorEmbedReturn && isQuizCarry())) return;
endQuizCarryEmbedPregameAndStart();
});
socket.on('gauntlet-crown-lobby-sync', (d) => {
if (!d || typeof d.readyMap !== 'object') return;
gauntletCrownLobbyReadyMap = { ...d.readyMap };
if (gauntletCrownPregamePhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateGauntletCrownHowtoHud();
}
});
socket.on('gauntlet-crown-lobby-started', () => {
if (!isGauntletCrownHeistMapPlay()) return;
beginGauntletCrownCountdownThenRun();
});
socket.on('user-move', (data) => {
if (data && myId != null && data.id === myId) {
/* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
if (mapData && mapData.gameType === 'gauntlet') {
return;
}
if (mapData && mapData.gameType === 'stack') {
return;
}
if (mapData && mapData.gameType === 'jump_survive') {
return;
}
if (mapData && mapData.gameType === 'space_shooter') {
if (data.spaceShooterScore != null) {
const s = Number(data.spaceShooterScore);
if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
}
return;
}
if (mapData && mapData.gameType === 'balloon_boss') {
if (data.balloonBossScore != null) {
const s = Number(data.balloonBossScore);
if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
}
if (data.balloonBossBalloons != null) {
const b = Number(data.balloonBossBalloons);
if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
}
if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
return;
}
if (mapData && mapData.gameType === 'quiz_battle') {
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) me.x = x;
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) me.y = y;
}
const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = s.x;
me.y = s.y;
if (data.direction) me.direction = data.direction;
if (data.characterId != null) me.characterId = data.characterId;
playPath = [];
return;
}
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) me.x = x;
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) me.y = y;
}
if (data.direction) me.direction = data.direction;
if (data.characterId != null) me.characterId = data.characterId;
playPath = [];
return;
}
const o = others.get(data.id);
if (o) {
if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
const px = Number(data.x);
const py = Number(data.y);
if (Number.isFinite(px) && Number.isFinite(py)) {
const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
o.tx = sn.x;
o.ty = sn.y;
o.x = sn.x;
o.y = sn.y;
} else {
o.tx = data.x;
o.ty = data.y;
}
} else {
o.tx = data.x;
o.ty = data.y;
}
o.direction = data.direction || o.direction;
if (data.characterId != null) o.characterId = data.characterId;
if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
const s = Number(data.spaceShooterScore);
if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
}
if (mapData && mapData.gameType === 'balloon_boss') {
if (data.balloonBossScore != null) {
const s = Number(data.balloonBossScore);
if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
}
if (data.balloonBossBalloons != null) {
const b = Number(data.balloonBossBalloons);
if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
}
if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
}
}
});
socket.on('user-left', (data) => {
if (!data || isPreviewBotId(data.id)) return;
others.delete(data.id);
if (previewFillBots) rebalancePreviewBots();
});
socket.on('chat', (data) => {
const box = document.getElementById('chat-messages');
if (!box) return;
const div = document.createElement('div');
div.className = 'chat-msg';
div.textContent = (data.nickname || '') + ': ' + (data.text || '');
box.appendChild(div);
box.scrollTop = 1e9;
});
socket.on('quiz-phase', (p) => {
if (previewMode) return;
if (!p || !mapData || !isQuiz()) return;
if (p.text) playQuizText = p.text;
playQuizPhaseLocal = p.phase;
playQuizPhaseEndsAt = p.endsAt || 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 (ov) ov.classList.remove('is-hidden');
if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
updatePlayQuizTimerDisplay();
if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
});
socket.on('quiz-result', (r) => {
if (previewMode || !r) return;
if (r.scores) {
playLiveQuizScores = { ...r.scores };
renderPlayQuizScoreboard(playLiveQuizScores);
}
showPlayQuizFeedback(r);
});
socket.on('quiz-player-state', (st) => {
if (previewMode) return;
if (!st) return;
playQuizPlayerLocal = {
cannotTrue: !!st.cannotTrue,
cannotFalse: !!st.cannotFalse,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
};
});
socket.on('quiz-ended', () => {
if (previewMode && isQuiz()) return;
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
playQuizPhaseLocal = null;
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
const panel = document.getElementById('quiz-map-question-panel');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
}
playQuizText = '';
playQuizPhaseEndsAt = 0;
playLiveQuizScores = {};
hidePlayQuizHud();
});
socket.on('gauntlet-sync', (data) => {
if (!mapData || mapData.gameType !== 'gauntlet' || !data) return;
applyGauntletTimingFromServer(data);
if (Array.isArray(data.obstacles)) {
gauntletObstacles = data.obstacles;
pushGauntletObsRenderFrame(data.obstacles);
}
if (!Array.isArray(data.players)) return;
data.players.forEach((p) => {
if (!p || p.id == null || myId == null) return;
const pid = String(p.id);
const mid = String(myId);
if (pid === mid) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) me.tx = px;
if (Number.isFinite(py)) me.ty = py;
if (p.direction) me.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
me.gauntletEliminated = !!p.gauntletEliminated;
} else {
const o = others.get(p.id) ?? others.get(pid);
if (o) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) o.tx = px;
if (Number.isFinite(py)) o.ty = py;
if (p.direction) o.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
o.gauntletEliminated = !!p.gauntletEliminated;
}
}
});
if (previewFillBots) {
applyGauntletPreviewBotsAfterSync();
emitGauntletPreviewRowsToServer();
}
});
socket.on('gauntlet-ended', (data) => {
cancelQuizCarryResultEndAfterTimeup();
gauntletEndsAtMs = null;
gauntletCrownRunHeldRemote = false;
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc = document.getElementById('gauntlet-crown-countdown');
if (gcc) gcc.classList.add('is-hidden');
const hto = document.getElementById('gauntlet-crown-howto-overlay');
if (hto) hto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
if (data && data.crownMission) {
showGauntletCrownMissionOverlay(data.crownMission);
return;
}
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) {
titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
}
msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
listEl.innerHTML = '';
const ranks = (data && data.rankings) || [];
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'}${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
});
socket.on('game-start', (ev) => {
if (!ev || !ev.mapId) return;
const applySnap = (md) => {
mapData = md;
if (ev.mapId != null && String(ev.mapId).trim() !== '') {
playSessionMapId = String(ev.mapId).trim();
}
if (mapData.gameType !== 'space_shooter') {
spaceShooterGameEnded = false;
}
if (mapData.gameType !== 'balloon_boss') {
balloonBossGameEnded = false;
}
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
tileSize = mapData.tileSize || 32;
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
ev.peersSnap.forEach((p) => {
if (p.id != null && myId != null && String(p.id) === String(myId)) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { me.x = px; me.tx = px; }
if (Number.isFinite(py)) { me.y = py; me.ty = py; }
me.direction = p.direction || me.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
me.gauntletEliminated = !!p.gauntletEliminated;
} else {
const o = others.get(p.id) ?? others.get(String(p.id));
if (o) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { o.x = px; o.tx = px; }
if (Number.isFinite(py)) { o.y = py; o.ty = py; }
o.direction = p.direction || o.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
o.gauntletEliminated = !!p.gauntletEliminated;
}
}
});
} else {
meGauntletJumpTicks = 0;
meGauntletJumpVis = 0;
me.gauntletScore = 0;
me.gauntletEliminated = false;
others.forEach((o) => {
o.gauntletJumpTicks = 0;
o.gauntletJumpVis = 0;
o.gauntletScore = 0;
o.gauntletEliminated = false;
});
}
if (mapData.gameType === 'gauntlet') {
if (ev.gauntletEndsAt != null) {
const ge = Number(ev.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
} else {
gauntletEndsAtMs = null;
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {});
if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) {
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc0 = document.getElementById('gauntlet-crown-countdown');
if (gcc0) gcc0.classList.add('is-hidden');
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
} else {
const hto2 = document.getElementById('gauntlet-crown-howto-overlay');
if (hto2) hto2.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc1 = document.getElementById('gauntlet-crown-countdown');
if (gcc1) gcc1.classList.add('is-hidden');
}
} else {
gauntletEndsAtMs = null;
const skipHideCrownShell = mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay();
if (!skipHideCrownShell) {
const hto3 = document.getElementById('gauntlet-crown-howto-overlay');
if (hto3) hto3.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
}
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc2 = document.getElementById('gauntlet-crown-countdown');
if (gcc2) gcc2.classList.add('is-hidden');
}
if (mapData.gameType !== 'gauntlet') {
me.tx = null;
me.ty = null;
}
const gauntletOv = document.getElementById('gauntlet-ended-overlay');
if (gauntletOv) gauntletOv.classList.add('is-hidden');
const gcmOv = document.getElementById('gauntlet-crown-mission-overlay');
if (gcmOv) gcmOv.classList.add('is-hidden');
if (mapData.gameType === 'stack') {
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
if (!mapData.stackLandArea) mapData.stackLandArea = [];
rebalancePreviewBots();
lastStackTickMs = performance.now();
resetStackMinigameState();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
else reapplyStackMiniSizingFromGlobals();
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
});
} else {
stackMini = null;
}
if (mapData.gameType === 'jump_survive') {
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
normalizeJumpSurvivePlatformAreaInPlay(mapData);
jumpSurviveEliminated = false;
jumpSurviveGameEnded = false;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
if (isJumpSurviveMissionUiMapPlay()) {
jumpSurviveMissionPhase = 'howto';
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60);
} else {
jumpSurviveMissionPhase = null;
jumpSurviveInitRuntime();
}
}
if (mapData.gameType === 'space_shooter') {
spaceShooterGameEnded = false;
normalizeShooterSpawnSlotsInPlay(mapData);
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterPopups = [];
spaceShooterLastTickMs = performance.now();
spaceShooterSpawnAccMs = 0;
spaceShooterFireCd = 0;
spaceShooterSessionStartMs = performance.now();
spaceShooterLastMoveEmit = 0;
}
if (mapData.gameType === 'balloon_boss') {
balloonBossGameEnded = false;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossHitFx = [];
balloonBossLastTickMs = performance.now();
balloonBossPlayerFireCd = 0;
balloonBossSessionStartMs = performance.now();
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
me.balloonBossScore = 0;
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
me.balloonBossEliminated = false;
others.forEach((o) => {
o.balloonBossScore = 0;
o.balloonBossBalloons = balloonBossBalloonsStartPlay();
o.balloonBossEliminated = false;
});
}
if (mapData.gameType === 'quiz_battle') {
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
normalizeQuizBattlePathInPlay(mapData);
normalizeQuizBattleDomeInPlay(mapData);
if (quizBattlePathModeActive(mapData)) {
const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = ms.x;
me.y = ms.y;
others.forEach((o) => {
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
o.x = s.x;
o.y = s.y;
});
}
}
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
if (isQuiz()) setupPlayQuizUi();
else if (isQuizCarry()) setupPlayQuizCarryUi();
else if (isQuizBattle()) setupPlayQuizBattleUi();
else teardownPlayQuizUi();
resizeCanvas();
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
if (previewFillBots && mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(true);
emitGauntletPreviewRowsToServer();
}
if (previewFillBots && mapData.gameType === 'jump_survive') {
applyJumpSurvivePreviewSpawnLayout(true);
}
if (previewFillBots && mapData.gameType === 'space_shooter') {
applySpaceShooterSpawnLayoutPlay();
}
if (previewFillBots && mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
};
fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
.then((r) => (r.ok ? r.json() : null))
.then((md) => { if (md) applySnap(md); });
});
function resizeCanvas() {
const vw = window.innerWidth || document.documentElement.clientWidth || 800;
const vh = window.innerHeight || document.documentElement.clientHeight || 600;
const header = document.querySelector('.game-header');
const headerH = header && header.offsetHeight ? header.offsetHeight : 48;
const parent = canvas && canvas.parentElement;
let cw = canvas.clientWidth || 0;
let ch = canvas.clientHeight || 0;
if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
if (ch < 120) ch = Math.max(240, vh - headerH - 6);
if (cw < 120) cw = Math.max(320, vw);
canvas.width = Math.max(320, cw);
canvas.height = Math.max(240, ch);
syncQuizCarryEmbedCountdownLayout();
}
// ให้ scroll wheel ใช้ซูมเข้า/ออกแบบง่าย ๆ
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
zoom = Math.max(0.7, Math.min(2.5, zoom * factor));
}, { passive: false });
canvas.addEventListener('click', (e) => {
if (!mapData || !isStack() || isChatFocused()) return;
tryHumanStackDrop();
});
canvas.addEventListener('dblclick', (e) => {
if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const camX = me.x * tileSize;
const camY = me.y * tileSize;
const gx = (sx - canvas.width / 2) / zoom + camX;
const gy = (sy - canvas.height / 2) / zoom + camY;
const tx = Math.floor(gx / tileSize);
const ty = Math.floor(gy / tileSize);
const mw = mapData.width || 20, mh = mapData.height || 15;
if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
if (path.length <= 1) return;
playPath = path.slice(1);
});
function getCharacterFootprintWH(md) {
if (!md) return { cw: 1, ch: 1 };
let cw = Math.floor(Number(md.characterCellsW));
let ch = Math.floor(Number(md.characterCellsH));
const hasW = Number.isFinite(cw) && cw >= 1;
const hasH = Number.isFinite(ch) && ch >= 1;
if (hasW && hasH) {
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
}
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
return { cw: leg, ch: leg };
}
/**
* ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง
*/
function getCharacterCollisionFootprintWH(md) {
const { cw, ch } = getCharacterFootprintWH(md);
let colW = Math.floor(Number(md.characterCollisionW));
let colH = Math.floor(Number(md.characterCollisionH));
if (!Number.isFinite(colW) || colW < 1) colW = cw;
if (!Number.isFinite(colH) || colH < 1) colH = ch;
colW = Math.max(1, Math.min(cw, colW));
colH = Math.max(1, Math.min(ch, colH));
return { cw, ch, colW, colH };
}
/** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
function clampPlayEntityFootprintToMap(oEnt, md) {
if (!oEnt || !md) return;
const w = md.width || 20, h = md.height || 15;
const { cw, ch } = getCharacterFootprintWH(md);
const maxX = Math.max(0, w - cw + 0.999);
const maxY = Math.max(0, h - ch + 0.999);
if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
}
/** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
function separateClumpedPreviewBots() {
if (!previewFillBots || !mapData) return;
const ids = [...others.keys()].filter(isPreviewBotId);
const minD = 0.52;
const pushAmt = MOVE_SPEED * 0.5;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = others.get(ids[i]);
const b = others.get(ids[j]);
if (!a || !b) continue;
let ddx = b.x - a.x, ddy = b.y - a.y;
let d = Math.sqrt(ddx * ddx + ddy * ddy);
if (d >= minD) continue;
if (d < 1e-5) {
ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
ddy = ((i + j) % 3) - 1;
d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
}
const ux = -ddx / d, uy = -ddy / d;
const vx = ddx / d, vy = ddy / d;
const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
clampPlayEntityFootprintToMap(a, mapData);
clampPlayEntityFootprintToMap(b, mapData);
}
}
}
function quizTilesFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
const { cw, ch } = getCharacterFootprintWH(mapData);
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const maxTx = Math.min(w - 1, minTx + cw - 1);
const maxTy = Math.min(h - 1, minTy + ch - 1);
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
}
}
return s;
}
/** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */
function playEntityFootprintContainsTile(px, py, tx, ty) {
return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
}
/**
* กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
* blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
* รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
*/
function resolveBlockPlayerNoOverlapBoxWH(md) {
const m = md || mapData;
if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
const { cw, ch } = getCharacterFootprintWH(m);
let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
const hasW = Number.isFinite(w) && w >= 0;
const hasH = Number.isFinite(h) && h >= 0;
if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
w = Math.min(leg, cw);
h = Math.min(leg, ch);
} else {
if (!hasW) w = 0;
if (!hasH) h = 0;
}
const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
return { cw, ch, boxW, boxH };
}
/** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const offX = minTx + Math.floor((cw - boxW) / 2);
const offY = minTy + (ch - boxH);
for (let ty = offY; ty < offY + boxH; ty++) {
for (let tx = offX; tx < offX + boxW; tx++) {
if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
}
}
return s;
}
function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
}
/** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
function quizTilesWallCollisionFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
const { cw, ch, colW, colH } = getCharacterCollisionFootprintWH(mapData);
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const offX = minTx + Math.floor((cw - colW) / 2);
const offY = minTy + (ch - colH);
for (let ty = offY; ty < offY + colH; ty++) {
for (let tx = offX; tx < offX + colW; tx++) {
if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
}
}
return s;
}
function quizAnswerTileForbiddenForLock(lock, tx, ty) {
if (!lock || lock.eliminated) return false;
if (!mapData) return false;
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
if (lock.cannotTrue && qt && qt[ty] && qt[ty][tx] === 1) return true;
if (lock.cannotFalse && qf && qf[ty] && qf[ty][tx] === 1) return true;
return false;
}
function quizAnswerTileForbiddenPlay(tx, ty) {
if (!playQuizPlayerLocal) return false;
return quizAnswerTileForbiddenForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, tx, ty);
}
/** ยืนทับโซนต้องห้ามเมื่อโดนล็อก — ใช้กับ pathfind ปลายทาง / คลิก */
function quizLockFootprintBlocksForLock(lock, px, py) {
if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
}
return false;
}
function quizLockFootprintBlocksPlay(px, py) {
if (!playQuizPlayerLocal) return false;
return quizLockFootprintBlocksForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, px, py);
}
/** บล็อกเฉพาะการ «เข้า» ช่องตอบใหม่ — ให้เดินออกจากโซนได้ถ้าเคยยืนผิดแล้ว */
function quizLockWouldEnterForbiddenForLock(lock, ox, oy, nx, ny) {
if (!mapData || !isQuiz() || !lock || lock.eliminated) return false;
const fromS = quizTilesFootprintPlay(ox, oy);
const toS = quizTilesFootprintPlay(nx, ny);
for (const k of toS) {
if (fromS.has(k)) continue;
const p = k.split(',');
const txi = +p[0], tyi = +p[1];
if (quizAnswerTileForbiddenForLock(lock, txi, tyi)) return true;
}
return false;
}
function quizLockWouldEnterForbiddenPlay(ox, oy, nx, ny) {
if (!playQuizPlayerLocal) return false;
return quizLockWouldEnterForbiddenForLock({
cannotTrue: !!playQuizPlayerLocal.cannotTrue,
cannotFalse: !!playQuizPlayerLocal.cannotFalse,
eliminated: !!playQuizPlayerLocal.eliminated,
}, ox, oy, nx, ny);
}
function botQuizLock(o) {
return {
cannotTrue: !!(o && o.quizCannotTrue),
cannotFalse: !!(o && o.quizCannotFalse),
eliminated: false,
};
}
/** Same walkability as room-lobby `canWalkLobby` (LobbyA / hall). */
function canWalkLikeLobby(x, y, fromX, fromY) {
if (!mapData || !mapData.objects) return false;
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const wallTiles = quizTilesWallCollisionFootprintPlay(x, y);
for (const k of wallTiles) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects[ty];
if (!row || row[tx] === 1) return false;
}
if (wallTiles.size === 0) return false;
const bp = mapData.blockPlayer;
if (bp) {
for (const k of quizTilesFootprintPlay(x, y)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (!bp[ty] || bp[ty][tx] !== 1) continue;
for (const [, o] of others) {
if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false;
}
}
}
if (isQuiz() && playQuizPlayerLocal && !playQuizPlayerLocal.eliminated) {
const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
if (hasFrom) {
if (quizLockWouldEnterForbiddenPlay(fromX, fromY, x, y)) return false;
} else if (quizLockFootprintBlocksPlay(x, y)) {
return false;
}
}
if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
}
return true;
}
function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) {
if (!mapData || !mapData.objects) return false;
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
const w = mapData.width || 20, h = mapData.height || 15;
const wallTilesB = quizTilesWallCollisionFootprintPlay(x, y);
for (const k of wallTilesB) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = mapData.objects[ty];
if (!row || row[tx] === 1) return false;
}
if (wallTilesB.size === 0) return false;
const bp = mapData.blockPlayer;
if (bp) {
for (const k of quizTilesFootprintPlay(x, y)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (!bp[ty] || bp[ty][tx] !== 1) continue;
for (const [, peer] of others) {
if (o && peer === o) continue;
if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false;
}
if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false;
}
}
if (isQuiz()) {
const lock = botQuizLock(o);
if (lock.cannotTrue || lock.cannotFalse) {
const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY);
if (hasFrom) {
if (quizLockWouldEnterForbiddenForLock(lock, fromX, fromY, x, y)) return false;
} else if (quizLockFootprintBlocksForLock(lock, x, y)) {
return false;
}
}
}
if (isQuizCarry() && quizCarryFootprintOverlapsHub(x, y)) return false;
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
if (!quizBattleFootprintFullyOnPath(mapData, x, y)) return false;
}
return true;
}
/** ถ้าสปอว์น/โหลดมาทับโซน hub ให้หาจุดใกล้ ๆ ที่ footprint ไม่ทับ hub */
function snapPositionOutOfQuizCarryHubIfNeeded(x, y) {
if (!mapData || !isQuizCarry()) return { x, y };
if (!quizCarryFootprintOverlapsHub(x, y)) return { x, y };
const w = mapData.width || 20, h = mapData.height || 15;
const maxR = Math.max(w, h) + 6;
for (let r = 1; r <= maxR; r++) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
const nx = x + dx * 0.45;
const ny = y + dy * 0.45;
if (nx < 0 || ny < 0 || nx > w - 0.02 || ny > h - 0.02) continue;
if (quizCarryFootprintOverlapsHub(nx, ny)) continue;
if (!canWalkLikeLobby(nx, ny, NaN, NaN)) continue;
return { x: nx, y: ny };
}
}
}
return { x, y };
}
function stepPreviewBotAlongPath(o, w, h) {
const path = o.botPath;
if (!path || !path.length) return;
const way = path[0];
const dx = way.x - o.x, dy = way.y - o.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= PATH_ARRIVE_THRESH) {
path.shift();
while (path.length > 0) {
const w2 = path[0];
const ux = w2.x - o.x, uy = w2.y - o.y;
if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
path.shift();
}
clampPlayEntityFootprintToMap(o, mapData);
return;
}
const len = dist || 1;
const pathStepMul = (previewFillBots && editorEmbedReturn) ? 0.56 : 1;
const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul, len);
const nx = o.x + (dx / len) * step;
const ny = o.y + (dy / len) * step;
if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
const ox = o.x, oy = o.y;
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else if (!pathStrict) {
if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
} else {
/* เส้นตรงไป waypoint อาจตัดมุม hub / block — ลองก้าวแนวแกนตามทิศหลักอย่างใดอย่างหนึ่ง */
const cstep = Math.min(MOVE_SPEED * 1.2 * pathStepMul, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
const tryAxisX = () => {
if (sx === 0) return false;
if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
o.x += sx * cstep;
return true;
};
const tryAxisY = () => {
if (sy === 0) return false;
if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
o.y += sy * cstep;
return true;
};
if (Math.abs(dx) >= Math.abs(dy)) {
if (!tryAxisX()) tryAxisY();
} else {
if (!tryAxisY()) tryAxisX();
}
}
}
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
o.botIsWalking = true;
o.botPathStuckTicks = 0;
} else {
o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
if ((o.botPathStuckTicks | 0) >= 20) {
o.botPathStuckTicks = 0;
if (path.length) path.shift();
if (isQuizCarry() && previewFillBots) o.botQuizCarryPathfindAfter = 0;
}
}
}
function pickRandomPreviewBotWanderDir() {
const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
return dirs[Math.floor(Math.random() * dirs.length)];
}
function stepPreviewBots() {
if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
if (isQuizCarry()) {
stepQuizCarryPreviewBots();
return;
}
const w = mapData.width || 20, h = mapData.height || 15;
const now = Date.now();
const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botIsWalking = false;
if (inAnswerPhase && o.botPath && o.botPath.length > 0 && !o.botAnswerWander) {
stepPreviewBotAlongPath(o, w, h);
return;
}
if (inAnswerPhase && o.botPath && o.botPath.length === 0 && !o.botAnswerWander) {
return;
}
/* ก่อนตอบ / พัก / บอทสับสน: เดินทุกเฟรมตามทิศ (เหมือนผู้เล่นกดค้าง) — เดิมรอเป็นจังหวะแล้วก้าวทีละครั้งเลยดูกระตุก */
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
if (now >= o.botWanderNextTurn) {
o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
if (Math.random() < 0.55) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
}
const accX = o.botWanderDx;
const accY = o.botWanderDy;
if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
const step = MOVE_SPEED;
const nx = o.x + accX * step;
const ny = o.y + accY * step;
const ox = o.x, oy = o.y;
const pathStrictW = isQuizBattle() && quizBattlePathModeActive(mapData);
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else if (!pathStrictW) {
if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
} else {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
}
} else {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
}
if (!Number.isFinite(o.x)) o.x = 0.5;
if (!Number.isFinite(o.y)) o.y = 0.5;
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
});
separateClumpedPreviewBots();
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const ob = others.get(bid);
if (ob) clampPlayEntityFootprintToMap(ob, mapData);
});
if (isQuizBattle() && quizBattlePathModeActive(mapData)) {
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
o.x = s.x;
o.y = s.y;
o.tx = s.x;
o.ty = s.y;
});
}
}
/** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */
function pathfindPlay(fromX, fromY, toX, toY) {
if (!mapData) return [];
const w = mapData.width || 20, h = mapData.height || 15;
const fx = Math.floor(fromX), fy = Math.floor(fromY);
const tx = Math.floor(toX), ty = Math.floor(toY);
if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return [];
if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
const key = (gx, gy) => gx + ',' + gy;
const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
const closed = new Set();
const cameFrom = {};
const gScore = { [key(fx, fy)]: 0 };
const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const ck = key(cur.gx, cur.gy);
if (closed.has(ck)) continue;
closed.add(ck);
if (cur.gx === tx && cur.gy === ty) {
const path = [];
let u = cur;
while (u) {
path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
u = cameFrom[key(u.gx, u.gy)];
}
return path;
}
for (const d of dirs) {
const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue;
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const g = (gScore[ck] ?? Infinity) + 1;
if (g >= (gScore[nk] ?? Infinity)) continue;
gScore[nk] = g;
cameFrom[nk] = cur;
open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
}
}
return [];
}
function pathfindPlayForBot(fromX, fromY, toX, toY, o) {
if (!mapData) return [];
const w = mapData.width || 20, h = mapData.height || 15;
const fx = Math.floor(fromX), fy = Math.floor(fromY);
const tx = Math.floor(toX), ty = Math.floor(toY);
if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return [];
if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }];
const key = (gx, gy) => gx + ',' + gy;
const open = [{ gx: fx, gy: fy, f: 0, g: 0 }];
const closed = new Set();
const cameFrom = {};
const gScore = { [key(fx, fy)]: 0 };
const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty);
const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }];
while (open.length) {
open.sort((a, b) => a.f - b.f);
const cur = open.shift();
const ck = key(cur.gx, cur.gy);
if (closed.has(ck)) continue;
closed.add(ck);
if (cur.gx === tx && cur.gy === ty) {
const path = [];
let u = cur;
while (u) {
path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 });
u = cameFrom[key(u.gx, u.gy)];
}
return path;
}
for (const d of dirs) {
const nx = cur.gx + d.dx, ny = cur.gy + d.dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue;
const nk = key(nx, ny);
if (closed.has(nk)) continue;
const g = (gScore[ck] ?? Infinity) + 1;
if (g >= (gScore[nk] ?? Infinity)) continue;
gScore[nk] = g;
cameFrom[nk] = cur;
open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g });
}
}
return [];
}
let playPath = [];
function drawGauntletLaserColumnScreen(rx, ry, rw, rh) {
ctx.save();
ctx.fillStyle = gauntletLaserFillColor;
ctx.fillRect(rx, ry, rw, rh);
const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null;
const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null;
const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null;
let topH = 0;
let botH = 0;
if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) {
topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth);
}
if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) {
botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth);
}
/* เส้นกลาง tile ทั้งความสูงคอลัมน์ แล้วค่อยวาดหัว/ท้ายทับ — ให้ลำแสงต่อเนื่องแบบสินทรัพย์รวม (รูปอ้างอิง) */
if (lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && rh > 1) {
const iw = lineRec.img.naturalWidth;
const ih = lineRec.img.naturalHeight;
const scale = rw / iw;
const step = Math.max(1, ih * scale);
let y = ry;
while (y < ry + rh) {
const piece = Math.min(step, ry + rh - y);
const srcH = piece / scale;
try {
ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece);
} catch (e) { /* ignore */ }
y += piece;
}
}
if (topH > 0 && topRec) {
try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ }
}
if (botH > 0 && botRec) {
try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ }
}
const lw = Number(gauntletLaserLineWidthPx) || 0;
if (lw > 0) {
ctx.strokeStyle = gauntletLaserStrokeColor;
ctx.lineWidth = lw;
ctx.strokeRect(rx + lw / 2, ry + lw / 2, rw - lw, rh - lw);
}
ctx.restore();
}
/** Stack preview: HUD แนว cyberdeck — SCORE ซ้าย, ตาปัจจุบันขวา, เทอร์มินัลล่างซ้าย */
function drawStackPreviewCyberHud(ctx, cw, ch) {
if (!playBotsEnabled() || !stackMini || !mapData || !isStack()) return;
const now = Date.now();
const pad = 12;
const currentSeat = getStackPreviewCurrentSeat();
const order = stackPreviewTurnOrder;
const mono = '11px ui-monospace, "Cascadia Mono", Consolas, monospace';
const monoSm = '9px ui-monospace, Consolas, monospace';
const lives = stackMini.lives != null ? stackMini.lives : 3;
const lifeSlots = 3;
const teamScore = stackMini.score || 0;
const combo = stackMini.combo || 0;
function roundRectPath(x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint) {
const timeMs = now;
const dir = 'down';
const rawImg = getAvatarImg(characterId, dir, timeMs, false);
const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
let iw = 0; let ih = 0;
if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
iw = charImg.width;
ih = charImg.height;
} else if (charImg && charImg.complete && charImg.naturalWidth) {
iw = charImg.naturalWidth;
ih = charImg.naturalHeight;
}
const ax = cx - maxSize / 2;
const ay = baselineY - maxSize;
ctx.save();
roundRectPath(ax, ay, maxSize, maxSize, 5);
ctx.clip();
if (iw > 0 && ih > 0) {
const scale = Math.min(maxSize / iw, maxSize / ih, 1);
const dw = iw * scale;
const dh = ih * scale;
ctx.drawImage(charImg, 0, 0, iw, ih, cx - dw / 2, baselineY - dh, dw, dh);
} else {
ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
ctx.fillRect(ax, ay, maxSize, maxSize);
}
ctx.restore();
ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
ctx.lineWidth = 1.5;
roundRectPath(ax, ay, maxSize, maxSize, 5);
ctx.stroke();
}
function resolveEntryVisual(entry) {
if (entry.kind === 'human') {
return {
name: String(me.nickname || 'YOU').toUpperCase(),
score: me.stackPreviewHumanPts || 0,
characterId: me.characterId,
playTint: me.playTint || playTintFromPeerId(String(myId || 'me')),
};
}
const o = entry.botId ? others.get(entry.botId) : null;
return {
name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
score: o ? (o.stackBotScore || 0) : 0,
characterId: o ? o.characterId : null,
playTint: o ? (o.playTint || playTintFromPeerId(entry.botId)) : null,
};
}
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const boardW = Math.min(248, Math.max(200, cw * 0.36));
const rowH = 42;
const headH = 38;
const boardH = headH + stackTurnCount * rowH + 10;
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
roundRectPath(pad, 8, boardW, boardH, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
ctx.lineWidth = 1.5;
roundRectPath(pad, 8, boardW, boardH, 8);
ctx.stroke();
ctx.fillStyle = '#5cefff';
ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('SCORE', pad + 14, 16);
ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
ctx.font = monoSm;
ctx.fillText('shared stack · P1P6', pad + 62, 18);
let rowY = 8 + headH;
const drawRows = (entries) => {
entries.forEach((entry) => {
const isCurrent = entry.seat === currentSeat;
const v = resolveEntryVisual(entry);
let actionFlash = false;
if (entry.kind === 'human') {
actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
} else if (entry.botId) {
const ob = others.get(entry.botId);
actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
}
if (isCurrent) {
ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
ctx.lineWidth = 2;
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.stroke();
} else if (actionFlash) {
ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
ctx.lineWidth = 1.5;
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.stroke();
}
const avS = 34;
drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
ctx.fillStyle = '#c0caf5';
ctx.font = 'bold 10px ' + mono;
ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
ctx.font = 'bold 13px ' + mono;
ctx.textAlign = 'right';
ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
ctx.textAlign = 'left';
rowY += rowH;
});
};
if (order && order.length === stackTurnCount) {
drawRows(order);
} else {
const bots = [...others.keys()].filter(isPreviewBotId).sort();
const rows = [{ kind: 'human', seat: 1 }];
for (let i = 0; i < stackTurnCount - 1; i++) {
rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
}
drawRows(rows);
}
const rightW = 136;
let rightX = cw - pad - rightW;
const topY = 8;
if (rightX < pad + boardW + 14) {
rightX = Math.max(pad, cw - pad - rightW);
}
const bigAv = Math.min(78, Math.max(56, ch * 0.11));
const turnPanelH = bigAv + 118;
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(187, 154, 247, 0.55)';
ctx.lineWidth = 1.5;
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
ctx.stroke();
let curEntry = null;
if (order && order.length === stackTurnCount) {
curEntry = order.find((e) => e.seat === currentSeat) || null;
}
const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
const framePad = 8;
const avCx = rightX + rightW / 2;
const bigFrameTop = topY + framePad - 2;
const bigFrameH = bigAv + 8;
ctx.strokeStyle = 'rgba(255, 120, 200, 0.75)';
ctx.lineWidth = 2;
roundRectPath(rightX + framePad - 2, topY + framePad - 2, rightW - (framePad - 2) * 2, bigAv + 8, 6);
ctx.stroke();
drawHudAvatar(avCx, bigFrameTop + bigFrameH - 4, bigAv, curV.characterId, curV.playTint);
ctx.fillStyle = '#f5c2e7';
ctx.font = 'bold 10px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('ACTIVE NODE', rightX + rightW / 2, topY + bigAv + 22);
ctx.fillStyle = '#7dfcff';
ctx.font = 'bold 11px ' + mono;
ctx.fillText('P' + currentSeat + ' · ' + curV.name.slice(0, 10), rightX + rightW / 2, topY + bigAv + 38);
ctx.fillStyle = '#bb9af7';
ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
ctx.fillText('SYSTEM INTEGRITY', rightX + rightW / 2, topY + bigAv + 58);
ctx.font = '14px sans-serif';
let heartStr = '';
for (let i = 0; i < lifeSlots; i++) {
heartStr += i < lives ? '♥ ' : '♡ ';
}
ctx.fillStyle = lives <= 1 ? '#f7768e' : '#9ece6a';
ctx.fillText(heartStr.trim(), rightX + rightW / 2, topY + bigAv + 74);
ctx.fillStyle = 'rgba(125, 252, 255, 0.85)';
ctx.font = monoSm;
ctx.fillText('TEAM ' + teamScore + ' · COMBO x' + combo, rightX + rightW / 2, topY + bigAv + 96);
const termW = Math.min(380, Math.max(220, cw * 0.42));
const termH = 86;
const termY = Math.max(boardH + 24, ch - termH - 52);
ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
roundRectPath(pad, termY, termW, termH, 6);
ctx.fill();
ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
ctx.lineWidth = 1.5;
roundRectPath(pad, termY, termW, termH, 6);
ctx.stroke();
ctx.fillStyle = '#c099ff';
ctx.font = '600 10px ' + mono;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
ctx.fillStyle = '#7dcfff';
ctx.font = monoSm;
let ly = termY + 26;
const logLines = stackPreviewHudLog.length
? stackPreviewHudLog.slice(-5)
: ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
logLines.forEach((ln) => {
ctx.fillText(ln, pad + 10, ly);
ly += 12;
});
const decryptPct = Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
const barY = termY + termH - 18;
const barW = termW - 20;
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(pad + 10, barY, barW, 8);
ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
ctx.fillRect(pad + 10, barY, Math.max(4, barW * decryptPct / 100), 8);
ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
ctx.strokeRect(pad + 10, barY, barW, 8);
ctx.fillStyle = '#94e2d5';
ctx.font = monoSm;
ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + 10, barY - 14);
ctx.restore();
}
function useCyberPlayHud() {
return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()));
}
/** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
function cyberHudQuizCarryPeerScore(peerId) {
if (peerId == null) return 0;
return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
}
function syncPlayCyberHud() {
const root = document.getElementById('play-cyber-hud');
const stackEl = document.getElementById('play-canvas-stack');
const on = useCyberPlayHud();
if (root) {
root.classList.toggle('is-hidden', !on);
root.setAttribute('aria-hidden', on ? 'false' : 'true');
}
if (stackEl) stackEl.classList.toggle('play-cyber-vignette', !!on);
const gw = document.querySelector('.game-wrap');
if (gw) gw.classList.toggle('play-cyber-active', !!on);
if (!on || !root) return;
const timeLabelEl = root.querySelector('.play-cyber-time-label');
if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : '';
const timeVal = document.getElementById('play-cyber-time-val');
const timeSub = document.getElementById('play-cyber-time-sub');
const portrait = document.getElementById('play-cyber-portrait-img');
const statusEl = document.getElementById('play-cyber-self-status');
const hintEl = document.getElementById('play-cyber-hint');
const previewEl = document.getElementById('play-cyber-preview-line');
if (timeSub) {
timeSub.textContent = isQuizCarry()
? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)'
: (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
: (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
: (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS'
: (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK'))));
}
if (timeVal) {
if (isQuizCarry()) {
if (quizCarrySessionEnded) {
timeVal.textContent = '0';
} else if (previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now()) {
const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000));
timeVal.textContent = String(rem);
} else if (quizCarryPregameActive) {
timeVal.textContent = '···';
} else if (!quizCarryCurrent) {
timeVal.textContent = '—';
} else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) {
timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000)));
} else if (quizCarryAnswerCloseAt > Date.now()) {
timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000)));
} else {
timeVal.textContent = '0';
}
} else if (isGauntlet()) {
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) {
timeVal.textContent = '···';
} else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
const mm = Math.floor(rem / 60);
const ss = rem % 60;
timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
} else if (gauntletRuntimeTimeLimitSec > 0) {
timeVal.textContent = '···';
} else {
timeVal.textContent = '∞';
}
} else if (isSpaceShooter()) {
if (spaceShooterGameEnded) {
timeVal.textContent = '0';
} else {
const rem = spaceShooterRemainingSecPlay();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else if (isBalloonBoss()) {
if (balloonBossGameEnded) {
timeVal.textContent = '0';
} else {
const rem = balloonBossRemainingSecPlay();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) {
timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0');
} else {
const rem = jumpSurviveRemainingSecMission();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else {
const t = jumpSurviveSessionStartMs > 0
? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
: 0;
timeVal.textContent = String(t);
}
}
if (portrait) {
const cid = me.characterId || getPlayCharacterId();
const dir = (isGauntletFaceRightMapMno9kb07() ? 'right' : (me.direction || 'down'));
const nowT = Date.now();
const walk = !!me.isWalking;
const rawImg = getAvatarImg(cid, dir, nowT, walk);
const tint = me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me'));
const comp = rawImg && cid && tint
? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint)
: rawImg;
if (comp && comp.tagName === 'CANVAS' && comp.width > 0) {
try {
portrait.src = comp.toDataURL('image/png');
} catch (e) {
if (rawImg && rawImg.src) portrait.src = rawImg.src;
}
} else if (comp && comp.src) {
portrait.src = comp.src;
} else if (rawImg && rawImg.src) {
portrait.src = rawImg.src;
}
}
if (statusEl) {
if (isQuizCarry() && quizCarrySessionEnded) {
statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม';
} else if (isJumpSurvive() && jumpSurviveEliminated) {
statusEl.textContent = 'SPECTATOR · LINK SEVERED';
} else if (isBalloonBoss() && me.balloonBossEliminated) {
statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
} else {
statusEl.textContent = 'OPERATOR ONLINE';
}
}
if (hintEl) {
if (isQuizCarry()) {
hintEl.textContent = quizCarrySessionEnded
? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
: ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
} else if (isJumpSurvive()) {
if (isJumpSurviveMissionUiMapPlay()) {
hintEl.textContent = jumpSurviveEliminated
? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)'
: 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน';
} else {
hintEl.textContent = jumpSurviveEliminated
? 'Spectate remaining nodes · Exit room to reset session'
: 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
}
} else if (isSpaceShooter()) {
hintEl.textContent = spaceShooterGameEnded
? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
: 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
} else if (isBalloonBoss()) {
hintEl.textContent = balloonBossGameEnded
? 'จบแล้ว · Session ended — see overlay'
: (me.balloonBossEliminated
? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
: 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ');
} else if (isGauntletCrownHeistMapPlay()) {
hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end';
} else {
hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
}
}
if (previewEl) {
if (previewFillBots && mapData) {
const human = countPlayHumans();
const bots = [...others.keys()].filter(isPreviewBotId).length;
previewEl.textContent = `SIM · humans ${human} + bots ${bots} · target ${previewTargetHeadcount}`;
previewEl.classList.remove('is-hidden');
} else {
previewEl.textContent = '';
previewEl.classList.add('is-hidden');
}
}
const ul = document.getElementById('play-cyber-score-list');
if (!ul) return;
const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay();
const rows = [];
rows.push({
id: myId,
nickname: me.nickname || nick,
characterId: me.characterId || getPlayCharacterId(),
score: isQuizCarry() ? cyberHudQuizCarryPeerScore(myId)
: (isGauntlet() ? (me.gauntletScore || 0)
: (isSpaceShooter() ? (me.spaceShooterScore || 0)
: (isBalloonBoss() ? (me.balloonBossScore || 0)
: (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0)))),
y: safeYv(me.y),
eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated)
|| !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated),
isMe: true,
});
others.forEach((o, id) => {
rows.push({
id,
nickname: o.nickname || id.slice(0, 8),
characterId: o.characterId,
score: isQuizCarry() ? cyberHudQuizCarryPeerScore(id)
: (isGauntlet() ? (o.gauntletScore || 0)
: (isSpaceShooter() ? (o.spaceShooterScore || 0)
: (isBalloonBoss() ? (o.balloonBossScore || 0)
: (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0)))),
y: safeYv(o.y),
eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
|| !!(isBalloonBoss() && o.balloonBossEliminated)
|| !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated),
isMe: false,
});
});
let leaderId = null;
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) {
let mx = -1;
for (let i = 0; i < rows.length; i++) {
if (rows[i].score > mx) {
mx = rows[i].score;
leaderId = rows[i].id;
}
}
} else {
let best = Infinity;
for (let i = 0; i < rows.length; i++) {
if (!rows[i].eliminated && rows[i].y < best) {
best = rows[i].y;
leaderId = rows[i].id;
}
}
}
rows.sort((a, b) => {
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) {
if (b.score !== a.score) return b.score - a.score;
if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th');
}
if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
return a.y - b.y;
});
ul.innerHTML = '';
const cap = 8;
for (let r = 0; r < Math.min(cap, rows.length); r++) {
const row = rows[r];
const li = document.createElement('li');
li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '');
const av = document.createElement('img');
av.className = 'play-cyber-score-av';
av.alt = '';
const cid = row.characterId ? String(row.characterId) : '';
if (!cid) {
av.src = defaultAvatarImg.src;
} else {
const im = getCharacterImg(cid, 'down');
if (im && im.src) av.src = im.src;
}
const mid = document.createElement('div');
mid.className = 'play-cyber-score-mid';
const name = document.createElement('span');
name.className = 'play-cyber-score-name';
if (row.id === leaderId && leaderId != null) {
const crown = document.createElement('span');
crown.className = 'play-cyber-lead-crown';
crown.textContent = '♔';
crown.title = 'Leader';
name.appendChild(crown);
}
name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
const st = document.createElement('span');
st.className = 'play-cyber-score-state';
st.textContent = isQuizCarry()
? ''
: (row.eliminated
? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE'))
: (isBalloonBoss()
? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
const ox = others.get(row.id);
return ox ? (ox.balloonBossBalloons | 0) : 0;
})())))
: 'LINK_OK'));
mid.appendChild(name);
mid.appendChild(st);
const sc = document.createElement('span');
sc.className = 'play-cyber-score-val';
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) {
sc.textContent = String(row.score);
} else {
sc.textContent = row.eliminated ? '—' : String(r + 1);
}
li.appendChild(av);
li.appendChild(mid);
li.appendChild(sc);
ul.appendChild(li);
}
}
function draw() {
if (!mapData) return;
const w = mapData.width, h = mapData.height;
const stackCam = isStack() ? getStackCameraCentersPx() : null;
let camX = stackCam ? stackCam.px : me.x * tileSize;
let camY = stackCam ? stackCam.py : me.y * tileSize;
if (isJumpSurvive()) {
camX = jumpSurviveCamCenterX;
camY = jumpSurviveCamCenterY;
}
let zDraw = zoom;
if (isSpaceShooter() || isBalloonBoss()) {
const mwPx = w * tileSize, mhPx = h * tileSize;
camX = mwPx * 0.5;
camY = mhPx * 0.5;
zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
}
const halfW = canvas.width / (2 * zDraw);
const halfH = canvas.height / (2 * zDraw);
// world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
const worldMinX = camX - halfW;
const worldMaxX = camX + halfW;
const worldMinY = camY - halfH;
const worldMaxY = camY + halfH;
const mapWpx = w * tileSize, mapHpx = h * tileSize;
const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
const timeMs = Date.now();
if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
/* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */
ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const natW = mapBackgroundImg.naturalWidth;
const natH = mapBackgroundImg.naturalHeight;
const bgMinX = Math.max(0, worldMinX);
const bgMaxX = Math.min(mapWpx, worldMaxX);
const bgMinY = Math.max(0, worldMinY);
const bgMaxY = Math.min(mapHpx, worldMaxY);
if (bgMaxX > bgMinX && bgMaxY > bgMinY) {
const srcX = (bgMinX / mapWpx) * natW;
const srcY = (bgMinY / mapHpx) * natH;
const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
const destY = (bgMinY - camY) * zDraw + canvas.height / 2;
const destW = (bgMaxX - bgMinX) * zDraw;
const destH = (bgMaxY - bgMinY) * zDraw;
if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
}
}
} else {
ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (isSpaceShooter()) {
ctx.save();
for (let s = 0; s < 120; s++) {
const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
const br = (s % 4 === 0) ? 1.6 : 0.9;
ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
ctx.beginPath();
ctx.arc(sx, sy, br, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (isBalloonBoss()) {
ctx.save();
ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
for (let col = 0; col < 24; col++) {
const x = (col + 0.5) * (canvas.width / 24);
for (let r = 0; r < 18; r++) {
const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
}
}
ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
ctx.beginPath();
ctx.moveTo(0, canvas.height);
const baseY = canvas.height * 0.88;
for (let k = 0; k <= 40; k++) {
const bx = (k / 40) * canvas.width;
const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
ctx.lineTo(bx, by);
}
ctx.lineTo(canvas.width, canvas.height);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
function worldToScreen(wx, wy) {
const sx = (wx - camX) * zDraw + canvas.width / 2;
const sy = (wy - camY) * zDraw + canvas.height / 2;
return [sx, sy];
}
function drawGridImagePlacementsLayer() {
if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
const sp = mapData.gridImagePlacements[gi];
const wx0 = sp.x * tileSize;
const wy0 = sp.y * tileSize;
const ww = sp.w * tileSize;
const wh = sp.h * tileSize;
if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
let gim = mapGridImageImgs[sp.i];
let carrySpriteAlpha = 1;
if (isQuizCarry()) {
const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp);
const meHolding = me && me.quizCarryHeld != null;
let carryVisualActive = false;
if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true;
else if (domOpt == null && onHub && meHolding) carryVisualActive = true;
if (carryVisualActive) {
const gh = mapGridImageHeldImgs[sp.i];
if (gh && gh.complete && gh.naturalWidth) gim = gh;
else carrySpriteAlpha = 0.32;
}
}
if (!gim || !gim.complete || !gim.naturalWidth) continue;
const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
const sy0 = (wy0 - camY) * zDraw + canvas.height / 2;
const sw = ww * zDraw;
const sh = wh * zDraw;
ctx.save();
if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha;
ctx.beginPath();
ctx.rect(sx0, sy0, sw, sh);
ctx.clip();
ctx.drawImage(gim, sx0, sy0, sw, sh);
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
}
}
const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
if (showGrid) {
for (let y = startTileY; y <= endTileY; y++) {
const lane = isFrogger() ? getLane(y) : null;
let rowFill = null;
if (lane) {
if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
} else if (isGauntlet()) {
rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
} else if (isStack()) {
rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
} else if (isJumpSurvive()) {
rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
} else if (isSpaceShooter()) {
rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
} else if (isBalloonBoss()) {
rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
} else if (isQuizBattle()) {
rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
}
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw;
const ob = mapData.objects?.[y]?.[x] ?? 0;
if (ob === 1) {
ctx.fillStyle = 'rgba(65,72,104,0.92)';
ctx.fillRect(sx, sy, size, size);
ctx.strokeStyle = '#565f89';
ctx.strokeRect(sx, sy, size, size);
} else {
const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
ctx.fillRect(sx, sy, size, size);
}
}
}
}
}
drawGridImagePlacementsLayer();
if (showGrid) {
for (let y = startTileY; y <= endTileY; y++) {
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw;
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(158,206,106,0.8)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
if (isQuiz()) {
const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isQuizQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
if (isQuizT) {
ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
if (isQuizF) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
}
if (isQuizCarry()) {
const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
if (hub) {
ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isCarryQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
}
}
if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
ctx.fillStyle = 'rgba(186, 130, 255, 0.4)';
ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
ctx.strokeStyle = 'rgba(220, 170, 255, 0.55)';
ctx.strokeRect(sx + 1, sy + 1, size - 2, size - 2);
}
if (isQuizBattle() && showGrid && mapData.quizBattleDomeArea && mapData.quizBattleDomeArea[y] && mapData.quizBattleDomeArea[y][x] === 1) {
const cid = mapData.quizBattleDomeComp && mapData.quizBattleDomeComp[y] && mapData.quizBattleDomeComp[y][x];
const done = cid > 0 && quizBattleAnsweredComps.has(cid);
ctx.fillStyle = done ? 'rgba(90, 90, 110, 0.4)' : 'rgba(100, 200, 255, 0.36)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = done ? 'rgba(140, 140, 160, 0.65)' : 'rgba(255, 100, 130, 0.82)';
ctx.lineWidth = 2;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
}
if (isStack()) {
const sr = mapData.stackReleaseArea, sl = mapData.stackLandArea;
if (sr && sr[y] && sr[y][x] === 1) {
ctx.fillStyle = 'rgba(125, 207, 255, 0.28)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.55)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
if (sl && sl[y] && sl[y][x] === 1) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.26)';
ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.5)';
ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
}
}
if (isBalloonBoss() && showGrid && mapData.balloonBossPlayerSlots) {
const sv = mapData.balloonBossPlayerSlots[y] && mapData.balloonBossPlayerSlots[y][x];
if (sv >= 1 && sv <= 6) {
ctx.fillStyle = 'rgba(255, 120, 200, 0.12)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(255, 160, 220, 0.45)';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(255, 228, 250, 0.92)';
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
ctx.textAlign = 'left';
}
}
if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
if (sv >= 1 && sv <= 6) {
ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
ctx.textAlign = 'left';
}
}
}
}
if (isJumpSurvive() && showGrid && mapData.jumpSurvivePlatformArea) {
const pa = mapData.jumpSurvivePlatformArea;
const sc = jumpSurvivePlatformScrollPx;
const tsz = tileSize;
const period = h * tsz;
const visTop = worldMinY;
const visBot = worldMaxY;
const eps = 1e-6;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
if (!pa[py] || pa[py][px] !== 1) continue;
const tileWorldLeft = px * tsz;
const tileWorldRight = tileWorldLeft + tsz;
if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
const rawTop = py * tsz - sc;
const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
const kMax = Math.floor((visBot - rawTop - eps) / period);
if (kMax < kMin) continue;
for (let kk = kMin; kk <= kMax; kk++) {
const platTop = rawTop + kk * period;
if (platTop + tsz <= visTop || platTop >= visBot) continue;
const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
const psz = tsz * zDraw;
ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
ctx.fillRect(psx + 3, psy + 3, psz - 6, psz - 6);
ctx.strokeStyle = 'rgba(0, 255, 240, 0.55)';
ctx.lineWidth = 2;
ctx.strokeRect(psx + 3, psy + 3, psz - 6, psz - 6);
ctx.strokeStyle = 'rgba(120, 255, 255, 0.35)';
ctx.lineWidth = 1;
ctx.strokeRect(psx + 5, psy + 5, psz - 10, psz - 10);
const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
ctx.fillStyle = grad;
ctx.fillRect(psx + 4, psy + 4, psz - 8, psz - 8);
ctx.lineWidth = 1;
if (zDraw >= 1.15 && psz > 22) {
ctx.save();
ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('DATA', psx + psz * 0.5, psy + psz * 0.38);
ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
ctx.fillText('/SEC/BLOCK', psx + psz * 0.5, psy + psz * 0.62);
ctx.restore();
}
}
}
}
}
if (isFrogger() && mapData.lanes) {
for (let y = startTileY; y <= endTileY; y++) {
const lane = getLane(y);
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
const positions = getVehiclePositions(lane, mapData.width, timeMs);
const isRoad = lane.type === 'road';
for (let i = 0; i < positions.length; i++) {
const vx = positions[i];
const wx = vx * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw * (isRoad ? 1.2 : 1.5);
ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
ctx.fillRect(sx, sy, size, size * 0.7);
ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
ctx.strokeRect(sx, sy, size, size * 0.7);
}
}
}
}
if (isQuizCarry()) {
drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
}
if (isStack() && stackMini) {
drawStackMinigame(ctx, worldToScreen, zDraw);
}
const gauntletObsDraw = isGauntlet() ? getGauntletObsDrawPositionsAt(performance.now()) : [];
if (isGauntlet() && gauntletObsDraw.length) {
const stx = Math.max(0, Math.floor(worldMinX / tileSize));
const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const sty = Math.max(0, Math.floor(worldMinY / tileSize));
const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
for (let i = 0; i < gauntletObsDraw.length; i++) {
const o = gauntletObsDraw[i];
if (!o) continue;
if (o.kind === 'lane' && typeof o.y === 'number') {
if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue;
const size = tileSize * zDraw;
const inner = Math.max(2, size - 4);
const [sxC, syCellBottom] = worldToScreen((o.drawX + 0.5) * tileSize, (o.y + 1) * tileSize);
const dx = sxC - inner / 2;
const dy = syCellBottom - inner;
const laneRec = pickGauntletLaneImageRec(o.id);
if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) {
try {
ctx.drawImage(laneRec.img, dx, dy, inner, inner);
} catch (e) {
ctx.fillStyle = '#f7768e';
ctx.fillRect(dx, dy, inner, inner);
}
} else {
ctx.fillStyle = '#f7768e';
ctx.fillRect(dx, dy, inner, inner);
}
} else if (o.kind === 'laser' && typeof o.drawX === 'number') {
if (o.drawX < stx - 2 || o.drawX > enx + 2) continue;
const { y0: laserRow0, y1: laserRow1 } = gauntletLaserRowHitRange(o, h);
const wx0 = o.drawX * tileSize;
const wx1 = (o.drawX + 1) * tileSize;
const wy0 = laserRow0 * tileSize;
const wy1 = (laserRow1 + 1) * tileSize;
const [sx0, syTop] = worldToScreen(wx0, wy0);
const [sx1, syBot] = worldToScreen(wx1, wy1);
const rx = Math.min(sx0, sx1);
const rw = Math.max(2, Math.abs(sx1 - sx0));
const ry = Math.min(syTop, syBot);
const rh = Math.abs(syBot - syTop);
drawGauntletLaserColumnScreen(rx, ry, rw, rh);
}
}
}
/** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */
function drawQuizCarryHeldSignBoard(sxFeet, feetSy, halfSpriteW, spriteTopY, signText, _faceDir, choiceIndex, imageUrl, imageElementOpt) {
const imgUrlSan = sanitizeQuizCarryImageUrlClient(imageUrl);
const gridHeldDraw = imageElementOpt && imageElementOpt.complete && imageElementOpt.naturalWidth > 0 ? imageElementOpt : null;
const raw = String(signText || '').trim();
if (!raw && !imgUrlSan && !gridHeldDraw) return;
ctx.save();
const heldPs = quizCarryPlaqueMapScaleClampedPlay();
const bodyH = Math.max(18, feetSy - spriteTopY);
const midBodyY = spriteTopY + bodyH * 0.48;
const bodyCx = sxFeet;
const halfW = Math.max(10, halfSpriteW || 0);
const baseMaxW = Math.min(280, Math.max(100, halfW * 2.4 + tileSize * zDraw * 0.5));
const maxPlaqueW = Math.min(340, Math.round(baseMaxW * heldPs));
const fontPx = Math.max(12, Math.min(22, tileSize * zDraw * 0.22 * heldPs));
ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", "Kanit", sans-serif`;
const padX = 12;
const padY = 9;
const maxInner = Math.min(maxPlaqueW - padX * 2, Math.max(72, tileSize * zDraw * 4.2));
let lines;
if (!raw) {
lines = [];
} else {
lines = canvasWordWrapLines(ctx, raw, maxInner);
if (!lines.length) lines = [raw];
if (lines.length > 3) lines = lines.slice(0, 3);
for (let i = 0; i < lines.length; i++) {
let s = lines[i];
if (ctx.measureText(s).width <= maxInner) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxInner) s = s.slice(0, -1);
lines[i] = s + '…';
}
}
let maxLineW = 0;
for (let i = 0; i < lines.length; i++) {
const lw = ctx.measureText(lines[i]).width;
if (lw > maxLineW) maxLineW = lw;
}
const lineH = fontPx * 1.22;
let w;
let h;
if (!lines.length && (imgUrlSan || gridHeldDraw)) {
w = Math.ceil(Math.min(maxPlaqueW, Math.max(104, maxInner + padX * 2)));
h = Math.ceil(Math.max(56, fontPx * 2.8 + padY * 2));
} else {
w = Math.ceil(Math.max(maxLineW, 56) + padX * 2);
h = Math.ceil(lines.length * lineH + padY * 2);
w = Math.min(maxPlaqueW, Math.max(96, w));
h = Math.max(40, h);
}
let signX = bodyCx - w / 2;
let signY = midBodyY - h / 2;
if (signX + w > canvas.width - 6) signX = canvas.width - w - 6;
if (signX < 6) signX = 6;
if (signY < 6) signY = 6;
if (signY + h > feetSy + 8) signY = feetSy + 8 - h;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, null, { imageUrl: imgUrlSan, imageElement: gridHeldDraw });
ctx.restore();
}
function drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel, quizCarrySignPayload) {
const { cw, ch } = getCharacterFootprintWH(mapData);
const air = Number(gauntletAirTicks) || 0;
let liftWorldY = 0;
if (isGauntlet() && air > 0) {
liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
}
const cxWorld = (ax + cw * 0.5) * tileSize;
/* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
const cellSpan = Math.max(cw, ch);
const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
const size = r * 2.2;
const dir = direction || 'down';
const rawImg = getAvatarImg(characterId, dir, timeMs, isWalking);
const charImg = playTint
? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, playTint)
: rawImg;
/* รองรับทั้ง <img> และ canvas จากย้อมสี (canvas ไม่มี naturalWidth → เดิมตกวงกลม) */
let iw = 0, ih = 0;
if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
iw = charImg.width;
ih = charImg.height;
} else if (charImg && charImg.complete && charImg.naturalWidth) {
iw = charImg.naturalWidth;
ih = charImg.naturalHeight;
}
let halfSpriteW = r;
let spriteTopY = sy - 2 * r;
if (iw > 0 && ih > 0) {
const scale = Math.min(size / iw, size / ih, 1);
const drawW = iw * scale;
const drawH = ih * scale;
halfSpriteW = drawW / 2;
spriteTopY = sy - drawH;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
} else {
const cy = sy - r;
ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
ctx.beginPath();
ctx.arc(sx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c0caf5';
ctx.lineWidth = 2;
ctx.stroke();
spriteTopY = cy - r;
}
ctx.fillStyle = '#c0caf5';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const nameBase = name || '';
const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
? (nameBase + ' · ' + gauntletScoreLabel)
: nameBase;
ctx.fillText(withScore, sx, sy + 10);
if (isQuizCarry() && quizCarrySignPayload) {
drawQuizCarryHeldSignBoard(
sx, sy, halfSpriteW, spriteTopY,
quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
quizCarrySignPayload.imageUrl,
quizCarrySignPayload.imageElement,
);
}
}
function quizCarrySignForEntity(ent) {
if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
const idx = ent.quizCarryHeld;
const ch = quizCarryCurrent.choices;
if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
const t = String(ch[idx] != null ? ch[idx] : '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
if (!t && !imgUrl && !gridHeldEl) return null;
return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
}
const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
function peerVisualOffset(id) {
let h = 0;
for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
}
const { ch: sortCh } = getCharacterFootprintWH(mapData);
/** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */
function avatarDrawDepth(ent) {
return Math.floor(safeY(ent.y)) + sortCh;
}
if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
let mt = me.playTint;
if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') {
mt = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local')));
me.playTint = mt;
}
const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking);
const drawRows = [];
others.forEach((o, id) => {
if (!o) return;
drawRows.push({ isMe: false, id, o });
});
drawRows.push({ isMe: true, id: '__me', o: me });
drawRows.sort((a, b) => {
const da = avatarDrawDepth(a.o);
const db = avatarDrawDepth(b.o);
if (da !== db) return da - db;
const xa = Math.floor(safeX(a.o.x) * 4);
const xb = Math.floor(safeX(b.o.x) * 4);
if (xa !== xb) return xa - xb;
const sa = a.isMe ? '\uFFFFme' : String(a.id);
const sb = b.isMe ? '\uFFFFme' : String(b.id);
return sa < sb ? -1 : sa > sb ? 1 : 0;
});
drawRows.forEach((row) => {
if (!row.isMe) {
const id = row.id;
const o = row.o;
const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
const otherWalk = isGauntlet()
? ((o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
: (isPreviewBotId(id)
? !!o.botIsWalking
: !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
const ot = o.playTint || playTintFromPeerId(id);
const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction;
const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
if (botOut) ctx.save();
if (botOut) ctx.globalAlpha = 0.4;
drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o));
if (botOut) ctx.restore();
} else {
if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me));
if (isJumpSurvive() && jumpSurviveEliminated) ctx.restore();
}
});
}
if (isFrogger()) {
ctx.fillStyle = '#7aa2f7';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24);
}
if (isJumpSurvive() && !useCyberPlayHud()) {
ctx.fillStyle = 'rgba(148, 226, 213, 0.95)';
ctx.font = '600 12px system-ui, "Segoe UI", "Kanit", sans-serif';
ctx.textAlign = 'left';
if (jumpSurviveEliminated) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.95)';
ctx.fillText('คุณตกรอบแล้ว (ชนหรือหลุดเพดาน/พื้นจอ) · เล่นต่อไม่ได้ — ดูเพื่อนได้ · รอรอบใหม่หรือออกจากห้อง', 10, 22);
} else {
ctx.fillText('กระโดดให้รอด · Space / W / ↑ · A D / ← → · แพลตฟอร์มวน · ชนหรือหลุดบน/ล่างจอ = ตกรอบ (ดูเพื่อนต่อ)', 10, 22);
}
}
if (isGauntlet() && !useCyberPlayHud()) {
ctx.fillStyle = '#f7768e';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
if (isGauntletCrownHeistMapPlay()) {
ctx.fillText('พรมแดงสุดท้าย | คะแนน ' + (me.gauntletScore || 0) + ' · ชน -10 · ขอบซ้ายตาย · สูงสุด 6 คน', 10, 24);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#c0caf5';
ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · จบเกมมีโบนัสอันดับ + เกรดทีม', 10, 42);
} else {
ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#c0caf5';
ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42);
}
ctx.fillStyle = '#e0af68';
if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
const mm = Math.floor(rem / 60);
const ss = rem % 60;
const clock = `${mm}:${String(ss).padStart(2, '0')}`;
ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
} else if (gauntletRuntimeTimeLimitSec > 0) {
ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
} else {
ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
}
}
if (isStack() && stackMini) {
if (previewFillBots) {
ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
} else {
ctx.fillStyle = '#7dcfff';
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#c0caf5';
ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
}
}
if (isQuiz() || isQuizCarry() || isQuizBattle()) {
syncPlayQuizMapPanel();
syncQuizCarryEmbedQuestionStrip();
}
/* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */
syncQuizCarryTimeupDeskLayerPosition();
if (previewFillBots && mapData && !useCyberPlayHud()) {
const human = countPlayHumans();
const bots = [...others.keys()].filter(isPreviewBotId).length;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(26,27,38,0.78)';
const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
ctx.fillRect(6, canvas.height - 38, hudW, 30);
ctx.fillStyle = '#a9b1d6';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
let line = 'ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)';
if (isStack()) {
line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
}
ctx.fillText(line, 12, canvas.height - 18);
ctx.restore();
}
if (isSpaceShooter()) {
drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
}
if (isBalloonBoss()) {
drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
}
if (isQuizCarry()) {
drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs);
}
if (previewMode && editorEmbedReturn && isQuizCarry() && quizCarryEmbedCountdownEndAt > Date.now()) {
syncQuizCarryEmbedCountdownLayout();
}
syncPlayCyberHud();
syncQuizCarryGrabButton();
}
document.addEventListener('keydown', (e) => {
if (isMovementKey(e.code) && isChatFocused()) return;
if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
}
if (isStack() && mapData && !isChatFocused()) {
if (e.code === 'Space') {
e.preventDefault();
tryHumanStackDrop();
return;
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
return;
}
}
if (isGauntlet() && mapData && !isChatFocused()) {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
if (isGauntletCrownPregameBlockingPlay()) return;
if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return;
const now = Date.now();
if (now - lastGauntletJumpKey < 200) return;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
return;
}
if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
return;
}
}
if (isJumpSurvive() && mapData && !isChatFocused()) {
if (isJumpSurviveMissionPregameBlockingPlay()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
return;
}
if (jumpSurviveEliminated) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
return;
}
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
if (!e.repeat) jumpSurviveJumpQueued = true;
return;
}
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
e.preventDefault();
return;
}
}
if (isQuizBattle() && mapData && !isChatFocused()) {
if (e.code === 'KeyE') {
e.preventDefault();
if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
return;
}
}
if (isQuizCarry() && mapData && !isChatFocused()) {
if (e.code === 'KeyF') {
e.preventDefault();
if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
return;
}
}
keys[e.code] = true;
keys[e.key] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if (isFrogger() && mapData && !isChatFocused()) {
const now = Date.now();
if (now - lastFroggerKey < 280) return;
let dx = 0, dy = 0;
if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
if (dx !== 0 || dy !== 0) {
const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
const row = mapData.objects && mapData.objects[ny];
if (!row || row[nx] !== 1) {
me.x = nx; me.y = ny;
lastFroggerKey = now;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
const lane = getLane(Math.floor(me.y));
if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
else if (checkFroggerCollision()) respawnFrogger();
}
}
}
}
});
document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
let lastSend = 0;
function tick() {
if (!mapData) { requestAnimationFrame(tick); return; }
if (isStack()) {
stackTickFrame();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isJumpSurvive()) {
if (isJumpSurviveMissionPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
jumpSurviveTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isSpaceShooter()) {
spaceShooterTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isBalloonBoss()) {
balloonBossTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isFrogger()) {
if (checkFroggerCollision()) respawnFrogger();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isGauntlet()) {
if (isGauntletCrownPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
me.isWalking = meGauntletJumpTicks > 0;
const stepGauntletJumpVis = (vis, tgt) => {
const t = Number(tgt) || 0;
let v = Number(vis) || 0;
const d = t - v;
const k = d < 0 ? 0.68 : 0.4;
return v + d * k;
};
meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
me.x = mp.nx;
me.y = mp.ny;
others.forEach((o) => {
const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
o.x = op.nx;
o.y = op.ny;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
});
draw();
requestAnimationFrame(tick);
return;
}
/* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
others.forEach((o, id) => {
if (previewFillBots && isPreviewBotId(id)) return;
if (o.tx != null) o.x += (o.tx - o.x) * LERP;
if (o.ty != null) o.y += (o.ty - o.y) * LERP;
});
tickQuizCarryEmbedCountdown();
tickQuizCarryRoundTimers();
updateQuizCarryCarryPhaseHud();
stepPreviewBots();
if (isChatFocused()) {
me.isWalking = false;
enforceQuizBattleLaneOnMePlay();
draw();
requestAnimationFrame(tick);
return;
}
if (isQuizCarryEmbedLobbyBlockingMovement() || isQuizCarryEmbedCountdownBlockingMovement()) {
me.isWalking = false;
playPath = [];
enforceQuizBattleLaneOnMePlay();
draw();
requestAnimationFrame(tick);
return;
}
const w = mapData.width || 20, h = mapData.height || 15;
let accX = 0, accY = 0;
let usePath = false;
const keyPressed = keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD'];
if (playPath.length > 0 && keyPressed) playPath = [];
if (playPath.length > 0) {
const way = playPath[0];
const dx = way.x - me.x, dy = way.y - me.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= PATH_ARRIVE_THRESH) {
playPath.shift();
/* ข้าม waypoint ที่ซ้ำตำแหน่ง — กัน acc=0 ทั้งที่ยังมี path (เดินขึ้น/ลงแล้วแอนิเมชันค้าง) */
while (playPath.length > 0) {
const w2 = playPath[0];
const ux = w2.x - me.x, uy = w2.y - me.y;
if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break;
playPath.shift();
}
if (playPath.length === 0) {
me.isWalking = false;
me.x = Math.max(0, Math.min(w - 0.01, me.x));
me.y = Math.max(0, Math.min(h - 0.01, me.y));
enforceQuizBattleLaneOnMePlay();
const t = Date.now();
if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
draw();
requestAnimationFrame(tick);
return;
}
usePath = true;
const next = playPath[0];
accX = next.x - me.x;
accY = next.y - me.y;
} else {
usePath = true;
accX = dx;
accY = dy;
}
if (usePath && (accX !== 0 || accY !== 0)) {
if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
else me.direction = accX > 0 ? 'right' : 'left';
}
}
if (!usePath) {
if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; }
if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; }
if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; }
if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; }
}
const preWalkX = me.x, preWalkY = me.y;
if (accX !== 0 || accY !== 0) {
const len = Math.sqrt(accX * accX + accY * accY) || 1;
const step = Math.min(MOVE_SPEED, len);
const nx = me.x + (accX / len) * step;
const ny = me.y + (accY / len) * step;
/** Quiz Battle + เส้นทาง: ห้ามเลื่อนแกนเดียว (slide) — ไม่งั้นเดินทแยงหลุดออกนอกเลนได้ */
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
if (canWalkLikeLobby(nx, ny, me.x, me.y)) {
me.x = nx;
me.y = ny;
} else if (!pathStrict) {
if (canWalkLikeLobby(nx, me.y, me.x, me.y)) {
me.x = nx;
} else if (canWalkLikeLobby(me.x, ny, me.x, me.y)) {
me.y = ny;
}
}
}
me.x = Math.max(0, Math.min(w - 0.01, me.x));
me.y = Math.max(0, Math.min(h - 0.01, me.y));
enforceQuizBattleLaneOnMePlay();
const movedThisTick = Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5;
me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick;
const now = Date.now();
if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
draw();
requestAnimationFrame(tick);
}
const chatForm = document.getElementById('chat-form');
if (chatForm) {
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('chat-input');
const text = (input && input.value || '').trim();
if (text) { socket.emit('chat', text); if (input) input.value = ''; }
});
}
document.getElementById('btn-leave').addEventListener('click', () => {
socket.emit('leave-space');
const mid = params.get('map');
if (previewMode && mid) {
var edQ = '?id=' + encodeURIComponent(mid) + (editorEmbedReturn ? '&embed=1' : '');
window.location.replace(BASE + '/editor.html' + edQ);
} else {
window.location.replace(BASE + '/lobby.html');
}
});
(function wireQuizBattleMcqModal() {
const ov = document.getElementById('quiz-battle-mcq-overlay');
if (!ov) return;
ov.addEventListener('click', (e) => {
const t = e.target;
if (!t) return;
if (t.classList && t.classList.contains('quiz-battle-choice')) {
const idx = parseInt(t.getAttribute('data-idx'), 10);
if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx);
return;
}
if (t.id === 'quiz-battle-mcq-close') {
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
return;
}
if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) {
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
}
});
})();
(function wireQuizCarryGrabButton() {
const btn = document.getElementById('quiz-carry-grab-btn');
if (!btn) return;
btn.addEventListener('click', (ev) => {
if (!isQuizCarry() || !mapData || isChatFocused()) return;
if (!btn.classList.contains('quiz-carry-grab-btn--active')) return;
ev.preventDefault();
if (myId == null) return;
tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
});
})();
(function wireGauntletCrownHowtoPrimary() {
const btn = document.getElementById('btn-gch-ready');
if (!btn) return;
btn.addEventListener('click', () => {
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
if (!(humans.length === 1 || isMePlayHost())) return;
beginJumpSurviveMissionCountdownThenRun();
return;
}
if (!isGauntletCrownHeistMapPlay()) return;
if (gauntletCrownPregamePhase !== 'howto') return;
if (myId == null || !isMePlayHost()) return;
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReady) {
if (humans.length === 1 && isMePlayHost()) {
beginGauntletCrownCountdownThenRun();
return;
}
socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
if (!r || !r.ok) { /* ignore */ }
});
return;
}
const sid = String(myId);
const next = !gauntletCrownLobbyReadyMap[sid];
gauntletCrownLobbyReadyMap[sid] = next;
updateGauntletCrownHowtoHud();
socket.emit('gauntlet-crown-lobby-ready', { ready: next });
});
})();
(function wireQuizCarryPregameOverlay() {
const primary = document.getElementById('quiz-carry-pregame-primary');
if (!primary) return;
primary.addEventListener('click', () => {
if (!quizCarryPregameActive || myId == null) return;
if (!isMePlayHost()) return;
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
if (humansReady) {
/* ผู้เล่นจริงคนเดียว + พรีวิว: ไม่ต้องรอ ACK เซิร์ฟ (ห้องที่ map บนเซิร์ฟยังไม่ใช่ quiz_carry จะเคยล้มเหลว) */
if (humans.length === 1 && isMePlayHost()) {
endQuizCarryEmbedPregameAndStart();
return;
}
socket.emit('quiz-carry-lobby-start', {}, (r) => {
if (!r || !r.ok) showQuizCarryToast((r && r.error) || 'START ไม่ได้', false);
});
return;
}
const sid = String(myId);
const next = !quizCarryLobbyReadyMap[sid];
quizCarryLobbyReadyMap[sid] = next;
updateQuizCarryPregameHud();
socket.emit('quiz-carry-lobby-ready', { ready: next });
});
})();
window.addEventListener('beforeunload', () => socket.emit('leave-space'));
window.addEventListener('resize', () => {
if (!mapData || !canvas) return;
resizeCanvas();
draw();
});
})();