(function () {
const BASE = '/Game';
function playDesignMainMenuHref() {
try {
if (typeof globalThis !== 'undefined' && typeof globalThis.designMainMenuHref === 'function') {
return globalThis.designMainMenuHref();
}
} catch (e) { /* ignore */ }
return '/Main-Menu/';
}
function playDesignMainLobbyHref() {
try {
if (typeof globalThis !== 'undefined' && typeof globalThis.designMainLobbyHref === 'function') {
return globalThis.designMainLobbyHref();
}
} catch (e) { /* ignore */ }
return '/Main-Lobby/';
}
/** รูปเลข 3–2–1 — เสิร์ฟที่ /Game/img/QUESTION/ (nginx alias /Game/ → public; อย่าใช้ /Game/public/img/) */
const COUNTDOWN_321_IMG_BASE = BASE + '/img/QUESTION/';
/** แผงคำถามบนแมป quiz_carry — ไฟล์อยู่ public/img/quiz-carry */
const QUIZ_CARRY_MAP_FEEDBACK_IMG = {
correct: BASE + '/img/quiz-carry/icon-Correct.png',
incorrect: BASE + '/img/quiz-carry/icon-Incorrect.png',
scorePlus: BASE + '/img/quiz-carry/score+10.png',
};
const QUIZ_MAP_CARRY_FEEDBACK_MS = 1400;
const params = new URLSearchParams(window.location.search);
const spaceId = params.get('space');
function readStoredDisplayName() {
try {
return (localStorage.getItem('roomLobbyDisplayName') || localStorage.getItem('playerName') || '').trim();
} catch (e) { return ''; }
}
let nick = (params.get('nick') || '').trim() || readStoredDisplayName() || 'ผู้เล่น';
const previewMode = params.get('preview') === '1';
const forceDefaultCharacter = params.get('defaultChar') === '1';
const editorEmbedReturn = params.get('editorEmbed') === '1';
if (previewMode && editorEmbedReturn) {
try { document.documentElement.classList.add('play-preview-editor-embed'); } catch (e) { /* ignore */ }
}
const playMapIdFromQuery = (params.get('map') || '').trim();
/** mapId จาก join / game-start — ใช้จับฉาก crown เมื่อ URL ไม่มี ?map= */
let playSessionMapId = playMapIdFromQuery;
/** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */
const quizCarryMissionSummaryMapId = 'mnorwqx1';
const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId;
/** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */
const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07';
/** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */
const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
/** Space shooter ฉาก Violent Crime — flow เดียวกับ crown/howto (รูปใน /img/ViolentCrime/) */
const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
/** Quiz ฉากสอบสวน — flow เดียวกับ crown/howto แต่รูปใน /img/QUESTION/ */
const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o';
/** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */
const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
/** Stack Tower: บัฟเฟอร์วาดคงที่ 16:9 — 1920×1080 */
const STACK_TOWER_FIXED_RENDER_W = 1920;
const STACK_TOWER_FIXED_RENDER_H = 1080;
/** คูณ zDraw ให้มุมมองโลกเท่าเดิมเมื่อบัฟเฟอร์กว้างกว่าฐานอ้างอิง 1280w (= 1.5 ที่ 1920w) */
const STACK_TOWER_FIXED_RENDER_Z_MUL = STACK_TOWER_FIXED_RENDER_W / 1280;
/** เชือก Stack Tower: ปลายล่างตามเส้น (1 = ถึงจุดยึดบล็อก) */
const STACK_TOWER_ROPE_DRAW_T1 = 1.0;
/** ปลายบนเชือก (จอ): ยืดเหนือขอบบนแคนวาสเป็นเศษส่วนของความสูง (เช่น 0.25 = 25% นอกจอด้านบน) */
const STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC = 0.25;
/** progress ≥ นี้ (ฉาก Tower mnn93hpi): รูป heavy + ความกว้าง heavy = เท่าเดียวกับปกติ × ค่าด้านล่าง + แอนิเมตซูม/เลื่อน */
const STACK_TOWER_POST50_PROGRESS_THRESH = 60;
const STACK_TOWER_POST50_ZOOM_MUL = 1.1;
/** เลื่อน BG แนวตั้งเท่าเศษส่วนของความสูงแคนวาส (จอ) */
const STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC = 0.1;
const STACK_TOWER_POST50_ANIM_MS = 680;
/** แมป mnn93hpi: สเกลความกว้าง/ความสูงชั้นของบล็อกในโลก (1 = ขนาดเดิม) */
const STACK_TOWER_BLOCK_WORLD_SCALE = 1.5;
/** หลังเกณฑ์ progress (Tower): เลื่อนกล้องลงในมุมมองจอเท่าเศษส่วนของความสูงแคนวาส (เช่น 0.25 = 25% ของความสูงจอ) */
const STACK_TOWER_POST50_VIEW_SHIFT_SCREEN_FRAC = 0.25;
/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */
const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
/** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */
const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9;
/** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */
const STACK_OVERLAP_MISS_MIN_ON_LAND = 0.42;
const STACK_OVERLAP_MISS_FRAC_ON_LAND = 0.14;
const STACK_OVERLAP_MISS_MIN_ON_LAYER = 0.4;
const STACK_OVERLAP_MISS_FRAC_ON_LAYER = 0.3;
/** จุดกลางแท่งที่วาง vs กลางโซน land — เกิน (ความกว้างโซน × นี้) = พลาด (ชั้นที่ 2 ขึ้นไป) */
const STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND = 0.38;
/** ไม่มี stackLandArea: ใช้กลางแมป — เยื้องได้ไม่เกิน fallW × นี้ (รวม) */
const STACK_MAX_CENTER_DRIFT_NO_LAND_MULT = 1.0;
/** overlap/fallW ต่ำกว่านี้ → เข้า settling (แท่งไม่นิ่ง) */
const STACK_STABLE_SUPPORT_RATIO_MIN = 0.62;
/** Stack ทั่วไป (ไม่ใช่ Tower post-50%): บล็อก heavy กว้างกว่าปกติ (~382×72 vs 314×103) */
const STACK_BLOCK_HEAVY_WIDTH_MULT = 382 / 314;
/** Tower live + progress ≥เกณฑ์ + heavy: ความกว้างบล็อก = ปกติ × ค่านี้ (เช่น 0.6 = 60% ของช่องปกติ — รูปใหญ่วาดในกรอบเดียวกัน) */
const STACK_TOWER_POST50_WIDTH_MULT = 0.6;
function stackBlockWidthTilesForPlay(baseTiles, heavy) {
const b = Number(baseTiles);
const base = Number.isFinite(b) ? Math.max(0.85, Math.min(3.2, b)) : 2;
const towerHeavyNarrow =
!!heavy &&
isStackTowerMissionUiMapPlay() &&
stackTowerMissionPhase === 'live' &&
stackMini &&
Number.isFinite(Number(stackMini.progressPct)) &&
Number(stackMini.progressPct) >= STACK_TOWER_POST50_PROGRESS_THRESH;
if (towerHeavyNarrow) {
const w = base * STACK_TOWER_POST50_WIDTH_MULT;
return Math.max(0.85, Math.min(3.2, Math.round(w * 200) / 200));
}
if (!heavy) return base;
const w = base * STACK_BLOCK_HEAVY_WIDTH_MULT;
return Math.min(3.2, Math.round(w * 200) / 200);
}
/** Violent Crime mission: asteroid strikes ship → invuln → 3 strikes = eliminated */
const SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS = 3;
const SPACE_SHOOTER_MISSION_HIT_INVULN_MS = 1400;
const SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS = 12;
/** อุกาบาต: HP สุ่ม 1–5 ต่อก้อน (ไม่วาดหัวใจบนก้อน) — ขนาดตาม HP เทียบสเกล 5 */
const SPACE_SHOOTER_ASTEROID_MAX_HP = 5;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL = 48;
const SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP = 11;
function spaceShooterAsteroidRadiusFromHpPlay(hp) {
const cap = SPACE_SHOOTER_ASTEROID_MAX_HP;
const h = Math.max(0, Math.min(cap, Math.floor(Number(hp)) || 0));
const frac = h <= 0 ? 0 : h / cap;
return SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP
+ (SPACE_SHOOTER_ASTEROID_RADIUS_AT_FULL - SPACE_SHOOTER_ASTEROID_RADIUS_AT_MIN_HP) * frac;
}
function spaceShooterShipFootprintCellsPlay(md) {
const m = md || mapData;
if (!m) return { cw: 1, ch: 1 };
const { cw, ch } = getCharacterFootprintWH(m);
return {
cw: Math.max(1, Math.min(4, cw)),
ch: Math.max(1, Math.min(4, ch)),
};
}
/** ขนาดวาดยานบนจอ — สเกลตาม footprint แผนที่ (editor: กว้าง/สูงตัวละคร) เทียบ tileSize */
function spaceShooterShipBodyScreenPxPlay(zDrawVal) {
const zD = Number(zDrawVal) > 0 ? zDrawVal : (typeof zoom === 'number' && zoom > 0 ? zoom : 1);
const ts = tileSize || 32;
const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
const kw = 14 / 32;
const kh = 22 / 32;
return {
bodyW: cw * ts * zD * kw,
bodyH: ch * ts * zD * kh,
};
}
/** รัศมีชนอุกาบาต (พิกัดโลก) — โตตาม footprint เพื่อให้สมกับยานที่วาดใหญ่ขึ้น */
function spaceShooterShipHitRadiusWorldPxPlay() {
if (!mapData || mapData.gameType !== 'space_shooter') return SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
const ts = tileSize || 32;
const { cw, ch } = spaceShooterShipFootprintCellsPlay(mapData);
const base = SPACE_SHOOTER_MISSION_SHIP_HIT_RADIUS;
const geom = Math.sqrt(Math.max(1, cw * ch));
const cap = Math.min(54, 0.42 * Math.min(cw * ts, ch * ts));
return Math.max(9, Math.min(cap, base * geom));
}
/** ระยะห่างจากขอบโลกซ้าย/ขวาเมื่อบังคับยาน — กันยานใหญ่หลุดกรอบ */
function spaceShooterShipEdgePadWorldPxPlay() {
if (!mapData || mapData.gameType !== 'space_shooter') return 18;
const ts = tileSize || 32;
const { cw } = spaceShooterShipFootprintCellsPlay(mapData);
return Math.max(18, cw * ts * 0.48);
}
function spaceShooterResetMissionShipStateForAllPlay() {
function resetEnt(ent) {
if (!ent) return;
ent.spaceShooterHits = 0;
ent.spaceShooterEliminated = false;
ent.spaceShooterInvulnUntil = 0;
}
resetEnt(me);
others.forEach(function (o) {
resetEnt(o);
});
}
function spaceShooterMissionResolveShipAsteroidHitsPlay() {
if (!mapData || mapData.gameType !== 'space_shooter') return;
if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return;
const now = performance.now();
const shipR = spaceShooterShipHitRadiusWorldPxPlay();
function tryHit(ent, cx, cy) {
if (!ent || ent.spaceShooterEliminated || cx == null || cy == null) return;
if (ent.spaceShooterInvulnUntil != null && now < ent.spaceShooterInvulnUntil) return;
for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
const a = spaceShooterAsteroids[ai];
if (!a) continue;
const dx = cx - a.x;
const dy = cy - a.y;
const rr = (a.r + shipR) * (a.r + shipR);
if (dx * dx + dy * dy > rr) continue;
ent.spaceShooterHits = Math.max(0, Number(ent.spaceShooterHits) || 0) + 1;
ent.spaceShooterInvulnUntil = now + SPACE_SHOOTER_MISSION_HIT_INVULN_MS;
spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
spaceShooterAsteroids.splice(ai, 1);
spaceShooterPopups.push({ x: cx, y: cy - 28, text: 'HIT', until: Date.now() + 650 });
if (ent.spaceShooterHits >= SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS) {
ent.spaceShooterEliminated = true;
spaceShooterPopups.push({ x: cx, y: cy - 50, text: 'OUT', until: Date.now() + 1400 });
}
break;
}
}
if (myId != null) tryHit(me, me.spaceShooterCx, me.spaceShooterCy);
others.forEach(function (o) {
if (!o) return;
tryHit(o, o.spaceShooterCx, o.spaceShooterCy);
});
if (spaceShooterMissionEveryParticipantEliminatedPlay()) {
endSpaceShooterMissionRound('all_dead');
}
}
/** Space Shooter ภารกิจ: ผู้เข้าร่วมทุกคนถูกกำจัดแล้ว (รวมเรา) */
function spaceShooterMissionEveryParticipantEliminatedPlay() {
if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) return false;
if (myId == null) return false;
if (!me.spaceShooterEliminated) return false;
let anyAlive = false;
others.forEach(function (o) {
if (!o) return;
if (!o.spaceShooterEliminated) anyAlive = true;
});
return !anyAlive;
}
/** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */
const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest';
let firstCharacterDefaultResolved = null;
/** รหัสตัวละครจาก GET /api/characters — ใช้สลับบอทพรีวิวให้ไม่ซ้ำรูปกับผู้เล่น */
let playCharacterIdRoster = [];
const firstCharacterDefaultPromise = fetch(BASE + '/api/characters')
.then((r) => r.json())
.then((list) => {
playCharacterIdRoster = [];
if (Array.isArray(list)) {
list.forEach((c) => {
if (c && c.id) playCharacterIdRoster.push(String(c.id));
});
}
if (Array.isArray(list) && list.length > 0 && list[0] && list[0].id) {
firstCharacterDefaultResolved = String(list[0].id);
} else {
firstCharacterDefaultResolved = '';
}
})
.catch(() => {
firstCharacterDefaultResolved = '';
playCharacterIdRoster = [];
})
.finally(() => {
try {
if (playBotsEnabled() && mapData) rebalancePreviewBots();
} catch (e) { /* map ยังไม่พร้อม */ }
});
const lobbyLevelParam = params.get('lobbyLevel');
const lobbyCaseParam = params.get('case');
if (lobbyLevelParam || lobbyCaseParam) {
try {
window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam };
} catch (e) { /* ignore */ }
}
const POST_CASE_LOBBY_MAP_ID = 'mn8nx46h';
function isDetectiveMinigamePlay() {
if (params.get('detectiveReturn') === '1') return true;
try {
return sessionStorage.getItem('detectiveMinigameReturn') === '1';
} catch (e) {
return false;
}
}
function buildRoomLobbyReturnHref() {
let h = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
h += '&map=' + encodeURIComponent(POST_CASE_LOBBY_MAP_ID);
const drParam = (params.get('displayRoom') || '').trim();
if (drParam) {
h += '&displayRoom=' + encodeURIComponent(drParam);
} else {
try {
const dr = localStorage.getItem('lastCreatedSpaceName');
if (dr) h += '&displayRoom=' + encodeURIComponent(dr);
} catch (e) { /* ignore */ }
}
try {
const meta = window.__detectiveLobbyMeta;
if (meta && meta.level != null && String(meta.level).trim()) {
h += '&lobbyLevel=' + encodeURIComponent(String(meta.level).trim());
}
if (meta && meta.caseId != null && String(meta.caseId).trim()) {
h += '&caseId=' + encodeURIComponent(String(meta.caseId).trim());
}
} catch (e2) { /* ignore */ }
try {
if (sessionStorage.getItem('detectiveMinigameReturn') === '1') {
h += '&detectiveReturn=1';
sessionStorage.removeItem('detectiveMinigameReturn');
}
} catch (e3) { /* ignore */ }
if (params.get('detectiveReturn') === '1' && h.indexOf('detectiveReturn') < 0) {
h += '&detectiveReturn=1';
}
return h;
}
function finishDetectiveMinigameAndReturnLobby() {
quizCarrySessionEnded = true;
cancelQuizCarryAnswerRoundTimeupAuto();
quizCarryAnswerTimeupAwaitNext = false;
hideQuizCarryTimeupOnDeskLayer();
try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ }
const go = function () {
window.location.href = buildRoomLobbyReturnHref();
};
if (socket && socket.connected) {
socket.emit('detective-minigame-finished', {}, function (res) {
go();
});
} else {
go();
}
}
/** จบมินิเกมโหมดสืบสวน → LobbyB; คืน true ถ้า redirect แล้ว */
function tryFinishDetectiveMinigameAndReturnLobby() {
if (!isDetectiveMinigamePlay()) return false;
finishDetectiveMinigameAndReturnLobby();
return true;
}
/** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */
const previewFillBots = previewMode && editorEmbedReturn;
let detectiveCaseFillBots = false;
let detectiveBotSlotCount = 0;
function playBotsEnabled() {
return previewFillBots || detectiveCaseFillBots;
}
function playBotTargetHeadcount() {
if (previewFillBots) return previewTargetHeadcount;
if (detectiveCaseFillBots) return countPlayHumans() + detectiveBotSlotCount;
return countPlayHumans();
}
/** พรีวิวใน iframe เอดิเตอร์: ซูมมุมมองได้ (เดิมไม่มี — zDraw จาก zoom คงที่) */
const PLAY_EMBED_USER_ZOOM_MIN = 0.5;
const PLAY_EMBED_USER_ZOOM_MAX = 3;
const PLAY_EMBED_ZOOM_STEP_KEY = 1.12;
let playEmbedUserZoomMul = 1;
let lastPlayZDrawForInput = 1.4;
/** คูณ world→พิกเซลบนแคนวาสให้ตรงกับ zDraw ใน draw() (รวม preview+embed zoom) — ใช้ซิงก์ DOM ทับฉาก */
function playDomSyncZoom() {
const z = lastPlayZDrawForInput;
return Number.isFinite(z) && z > 0 ? z : zoom;
}
const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6));
const PREVIEW_BOT_PREFIX = '__pv_bot_';
let previewBotSeq = 0;
/** Stack preview: บอทไม่วาบบนแผนที่ — ใช้ไฮไลต์แถบแข่งแทน */
let lastStackPreviewActorId = null;
let lastStackPreviewActorUntil = 0;
/** Stack preview: สลับตา Player 1 = มนุษย์, 2–6 = บอท (เฉพาะตาปัจจุบันเล่นได้) */
const STACK_PREVIEW_TURN_COUNT = 6;
let stackPreviewTurnOrder = null;
let stackPreviewTurnIndex = 0;
let stackPreviewBotThinkUntil = 0;
function rebuildStackPreviewTurnOrder() {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'stack') {
stackPreviewTurnOrder = null;
return;
}
const bots = [...others.keys()].filter(isPreviewBotId).sort();
stackPreviewTurnOrder = [{ kind: 'human', seat: 1 }];
for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
stackPreviewTurnOrder.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
}
stackPreviewTurnIndex = 0;
stackPreviewBotThinkUntil = 0;
markStackNextDropVisualDirty();
}
function advanceStackPreviewTurn() {
if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return;
stackPreviewTurnIndex = (stackPreviewTurnIndex + 1) % STACK_PREVIEW_TURN_COUNT;
stackPreviewBotThinkUntil = 0;
markStackNextDropVisualDirty();
}
function isStackPreviewHumanTurn() {
if (!playBotsEnabled() || !stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return true;
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
return !!(cur && cur.kind === 'human');
}
function getStackPreviewCurrentSeat() {
if (!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT) return 1;
const cur = stackPreviewTurnOrder[stackPreviewTurnIndex];
return cur && cur.seat ? cur.seat : stackPreviewTurnIndex + 1;
}
function getStackDropActorSeat() {
if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
return getStackPreviewCurrentSeat();
}
return 1;
}
function markStackNextDropVisualDirty() {
if (stackMini) stackMini.nextDropVisualDirty = true;
}
function pickStackBlockHeavyVisual(seat) {
const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
const hU = normalizeGauntletAssetUrlForPlay(playStackBlockHeavyUrls[slot] || '');
/** Tower mnn93hpi: ตั้งแต่เกณฑ์ progress (ตาม stackTowerProgressBlocks) → ใช้รูป heavy จาก admin — นอก live ไม่สุ่ม heavy */
if (isStackTowerMissionUiMapPlay()) {
if (!(stackTowerMissionPhase === 'live' && stackMini)) return false;
const p = Number(stackMini.progressPct);
if (!Number.isFinite(p) || p < STACK_TOWER_POST50_PROGRESS_THRESH) return false;
return !!hU;
}
const pct = Math.max(0, Math.min(100, Math.floor(Number(playStackHeavyBlockPercent) || 0)));
if (pct <= 0) return false;
if (!hU) return false;
if (Math.random() * 100 >= pct) return false;
return true;
}
function ensureStackNextDropVisual() {
if (!stackMini || !stackMini.nextDropVisualDirty) return;
stackMini.pendingDropSeat = getStackDropActorSeat();
stackMini.pendingDropHeavy = pickStackBlockHeavyVisual(stackMini.pendingDropSeat);
stackMini.widthTiles = stackBlockWidthTilesForPlay(stackMini.initialWidthTiles, !!stackMini.pendingDropHeavy);
stackMini.nextDropVisualDirty = false;
}
function normalizePlayStackSixUrlsFromTiming(arr) {
const out = ['', '', '', '', '', ''];
if (!Array.isArray(arr)) return out;
for (let i = 0; i < 6; i++) {
out[i] = normalizeGauntletAssetUrlForPlay(typeof arr[i] === 'string' ? arr[i] : '');
if (out[i]) ensureGauntletAssetImage(out[i]);
}
return out;
}
function resolveStackBlockSpriteRec(seat, heavy) {
const slot = Math.max(0, Math.min(5, (Math.floor(Number(seat)) || 1) - 1));
const tryHeavy = !!heavy;
const order = tryHeavy
? [playStackBlockHeavyUrls[slot], playStackBlockNormalUrls[slot]]
: [playStackBlockNormalUrls[slot], playStackBlockHeavyUrls[slot]];
for (let ti = 0; ti < order.length; ti++) {
const u = normalizeGauntletAssetUrlForPlay(order[ti] || '');
if (!u) continue;
return ensureGauntletAssetImage(u);
}
return null;
}
/**
* @returns {boolean} true = วาดสไปรต์แล้ว · false = วาดสี่เหลี่ยมสี fallback
* ใช้ scale แบบ cover + clip ให้ขอบภาพตรงกล่องฟิสิกส์ (drawW×drawH) — ไม่เว้นขอบซ้ายขวาแบบ contain
* เพื่อให้สายตาตรงกับ overlap / miss ใน computeStackDropResult
*/
function drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seat, heavy, hueFallback, opts) {
const o = opts || {};
const rec = resolveStackBlockSpriteRec(seat, heavy);
const img = rec && rec.img;
if (rec && rec.ready && img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
const iw = img.naturalWidth;
const ih = img.naturalHeight;
if (drawW > 0 && drawH > 0) {
const scale = Math.max(drawW / iw, drawH / ih);
const dw = iw * scale;
const dh = ih * scale;
const dx = sx + (drawW - dw) * 0.5;
const dy = sy + (drawH - dh) * 0.5;
const prevSmooth = ctx.imageSmoothingEnabled;
ctx.save();
ctx.beginPath();
ctx.rect(sx, sy, drawW, drawH);
ctx.clip();
try {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
} finally {
ctx.imageSmoothingEnabled = prevSmooth;
}
ctx.restore();
if (o.missOverlay) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.42)';
ctx.fillRect(sx, sy, drawW, drawH);
}
return true;
}
}
const hue = ((hueFallback % 360) + 360) % 360;
ctx.fillStyle = o.missTint ? 'rgba(247, 118, 190, 0.88)' : ('hsla(' + hue + ', 68%, 56%, 0.9)');
ctx.fillRect(sx, sy, drawW, drawH);
return false;
}
if (!spaceId) { window.location.replace(playDesignMainMenuHref()); return; }
const socket = io({ path: BASE + '/socket.io' });
window.__jdGameSocket = socket;
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let mapData = null, tileSize = 32, myId = null;
let stackMini = null;
let stackFall = null;
/** Tower / Stack — เชือกเหวี่ยงจาก `img/TowerBlock/sling.png` */
let stackTowerSlingImg = null;
/** Tower mnn93hpi — +คะแนน (`score+10.png` / `score+20.png`) + เอฟเฟกต์ ×2 (`score-light.png`, `scorex2.png`) */
let stackTowerScorePlus10Img = null;
let stackTowerScorePlus20Img = null;
let stackTowerScoreLightImg = null;
let stackTowerScoreX2Img = null;
/** พลาดแล้วโชว์ heart-1.png กลางเหนือกอง (mock Tower) — heart เต็มใน HUD ใช้ไฟล์เดียวกัน */
let stackTowerHeartMinusImg = null;
/** HUD มุมขวา: `life-bar.png` + `life-full.png` / `life-hit.png` (mock รูป2) */
let stackTowerLifeBarImg = null;
let stackTowerLifeHitImg = null;
let stackTowerLifeFullImg = null;
/** @type {{ until: number } | null} */
let stackTowerHeartMinusFx = null;
const STACK_TOWER_HEART_MINUS_FX_MS = 1500;
const STACK_TOWER_PERFECT_GLOW_MS = 1500;
/** ป๊อปอัป +10 / +20 เหนือบล็อก */
const STACK_TOWER_SCORE_POPUP_MS = 2000;
/** ระยะโชว์ score-light + scorex2 ข้างชั้นบน (คู่กับ +20 / perfect full) */
const STACK_TOWER_PERFECT_X2_MS = 2200;
/** ทับบล็อกก่อนหน้า + ชั้นใหม่: support เต็มแทบไม่มีเพี้ยน */
const STACK_TOWER_PERFECT_SUPPORT_MIN = 0.998;
let lastStackTickMs = performance.now();
let lastStackTowerScrollBgTickMs = performance.now();
let mapBackgroundImg = null;
/** space_shooter + แมป mnpz6rkp — พื้นหลังเลื่อนแนวตั้ง (ข้อมูลจาก editorBgScroll ในแมป) */
const PLAY_SCROLL_BG_MAP_ID = 'mnpz6rkp';
const PLAY_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnpz6rkp/intro.png';
const PLAY_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnpz6rkp/loop.png';
let playScrollBgIntroImg = null;
let playScrollBgLoopImg = null;
/** ขอบบนของ viewport ใน strip — เพิ่มค่า = เลื่อนลง strip (เข้า loop ใต้ intro) */
let playScrollBgPx = 0;
let playScrollBgSpeedPxPerSec = 56;
let playScrollBgOff = false;
/** true = บน→ล่าง: พลิกมุมมองใน viewport (ยังเลื่อน S+ เข้า loop ใต้ intro) */
let playScrollBgFlowDown = false;
function playScrollBgMapEligible() {
return !!(mapData && mapData.gameType === 'space_shooter' && (currentPlayMapId() || '').trim() === PLAY_SCROLL_BG_MAP_ID);
}
function playScrollBgDrawActive() {
if (!playScrollBgMapEligible() || playScrollBgOff) return false;
const a = playScrollBgIntroImg;
const b = playScrollBgLoopImg;
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
function reloadPlayScrollBgFromMap() {
playScrollBgIntroImg = null;
playScrollBgLoopImg = null;
playScrollBgPx = 0;
if (!playScrollBgMapEligible()) return;
const raw = mapData.editorBgScroll && typeof mapData.editorBgScroll === 'object' ? mapData.editorBgScroll : {};
playScrollBgOff = raw.enabled === false;
playScrollBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 56));
const dirRaw = String(raw.scrollDirection || raw.direction || 'up').toLowerCase();
playScrollBgFlowDown = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
if (playScrollBgOff) return;
const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : PLAY_SCROLL_BG_DEFAULT_INTRO;
const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : PLAY_SCROLL_BG_DEFAULT_LOOP;
let pending = 2;
const bump = () => {
pending--;
if (pending <= 0) {
playScrollBgSyncInitialScrollToBottom();
try { draw(); } catch (e) { /* ignore */ }
}
};
const im1 = new Image();
im1.onload = () => { playScrollBgIntroImg = im1; bump(); };
im1.onerror = () => { bump(); };
im1.src = introSrc;
const im2 = new Image();
im2.onload = () => { playScrollBgLoopImg = im2; bump(); };
im2.onerror = () => { bump(); };
im2.src = loopSrc;
}
function playScrollBgSyncInitialScrollToBottom() {
const intro = playScrollBgIntroImg;
if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
const cwR = Math.max(1, Math.round(canvas.width));
const chR = Math.max(1, Math.round(canvas.height));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
/* ล่าง→บน: S = HI−ch ให้ขอบล่าง intro ชิดล่างจอ · บน→ล่าง (พลิก dest): S=0 ถึงได้ขอบล่าง intro ชิดล่างจอ */
if (playScrollBgFlowDown) {
playScrollBgPx = 0;
} else {
playScrollBgPx = Math.max(0, drawHIntro - chR);
}
}
function drawPlayScrollBgFullCanvas(cw, ch) {
const intro = playScrollBgIntroImg;
const loop = playScrollBgLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
const drawHIntro = Math.round(intro.naturalHeight * scaleI);
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const S = playScrollBgPx;
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 2;
const flowDown = playScrollBgFlowDown;
function stripTopToDestY(stripTop, drawH) {
if (!flowDown) return stripTop - S;
return S + chR - (stripTop + drawH);
}
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
if (vp0 < 0) {
let k = 1;
if (vp0 < -drawHLoop * 4) {
k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
}
for (; k < 50000; k++) {
const y0 = -k * drawHLoop;
if (y0 >= vp1 + drawHLoop) break;
if (y0 + drawHLoop <= vp0) continue;
const dy = Math.round(stripTopToDestY(y0, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
}
}
if (drawHIntro > vp0 && vp1 > 0) {
const dy = Math.round(stripTopToDestY(0, drawHIntro));
ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
}
let y = drawHIntro;
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
y = drawHIntro + Math.max(0, n - 1) * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
y += drawHLoop;
}
}
ctx.restore();
}
function tickPlayScrollBg(dtSec) {
if (!playScrollBgDrawActive()) return;
playScrollBgPx += (playScrollBgSpeedPxPerSec || 56) * dtSec;
}
/** Last Light (mno9kb07) — พื้นหลังแนวนอน: start → loop 2→3→4 → finish ครั้งเดียว (ไม่วน) แล้วจบเกม + ซ่อนอุปสรรค (gauntletCrownRunwayBg ในแมป) */
const GAUNTLET_CROWN_RUNWAY_BG_MAP_ID = 'mno9kb07';
/** หลังหยุดเลื่อนที่จุด FINISH — รอก่อน latch + โฟลว์สรุป (มิลลิวิ) */
const GAUNTLET_CROWN_RUNWAY_POST_STOP_MS = 2000;
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START = BASE + '/img/editor-bg-mno9kb07/start.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2 = BASE + '/img/editor-bg-mno9kb07/loop2.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3 = BASE + '/img/editor-bg-mno9kb07/loop3.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4 = BASE + '/img/editor-bg-mno9kb07/loop4.png';
const GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH = BASE + '/img/editor-bg-mno9kb07/finish.png';
let gauntletCrownRunwayBgImgs = [null, null, null, null, null];
let gauntletCrownRunwayBgOff = false;
let gauntletCrownRunwayBgSpeedPxPerSec = 48;
let gauntletCrownRunwayBgScrollPx = 0;
let gauntletCrownRunwayBgFinishLatched = false;
/** >0 = หยุดเลื่อนแล้ว (timestamp ms) รอ POST_STOP ก่อน latch */
let gauntletCrownRunwayBgStripFreezeSinceMs = 0;
/** พรีวิว embed: แสดง GCM ครั้งเดียวเมื่อรันเวย์ครบแนว (ไม่รอเซิร์ฟเวอร์) */
let gauntletCrownRunwayClientMissionShown = false;
/** พรมแดง mno9kb07 — จำกัดขอบขวาของตัว (ขอบขวา footprint ไม่เกินสัดส่วนนี้ของความกว้างแมปเป็นช่อง) */
const GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC = 0.8;
const GAUNTLET_CROWN_SCORE_POP_URL = BASE + '/img/gauntlet-assets/score-.png';
let gauntletCrownScorePenaltyImg = null;
function ensureGauntletCrownScorePenaltyImgPlay() {
if (gauntletCrownScorePenaltyImg) return gauntletCrownScorePenaltyImg;
const im = new Image();
im.src = GAUNTLET_CROWN_SCORE_POP_URL;
gauntletCrownScorePenaltyImg = im;
return im;
}
let lastGauntletCrownRunwayBgTickMs = 0;
/** จัด offset เลื่อนรันเวย์ให้จุดเกิดตรงกับงานศิลป์ START (รูป 1) — รีเซ็ตตอน howto / โหลดแมปใหม่ */
let gauntletCrownRunwaySpawnScrollSnapped = false;
function gauntletCrownRunwayBgMapEligiblePlay() {
return !!(mapData && mapData.gameType === 'gauntlet' && (currentPlayMapId() || '').trim() === GAUNTLET_CROWN_RUNWAY_BG_MAP_ID);
}
function gauntletCrownRunwayBgNatDims(im) {
if (!im) return null;
if (im.tagName === 'CANVAS' && im.width > 0 && im.height > 0) return { nw: im.width, nh: im.height };
if (im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) return { nw: im.naturalWidth, nh: im.naturalHeight };
return null;
}
function gauntletCrownRunwayBgImageSlotReadyPlay(im) {
return !!gauntletCrownRunwayBgNatDims(im);
}
function gauntletCrownRunwayBgStripWidthsAtCh(chR) {
const ch0 = Math.max(1, Math.round(chR));
const wOf = (im) => {
const d = gauntletCrownRunwayBgNatDims(im);
if (!d) return 0;
return Math.max(1, Math.round(d.nw * (ch0 / d.nh)));
};
return {
w0: wOf(gauntletCrownRunwayBgImgs[0]),
w1: wOf(gauntletCrownRunwayBgImgs[1]),
w2: wOf(gauntletCrownRunwayBgImgs[2]),
w3: wOf(gauntletCrownRunwayBgImgs[3]),
w4: wOf(gauntletCrownRunwayBgImgs[4]),
};
}
function gauntletCrownRunwayBgImagesReadyPlay() {
for (let i = 0; i < 5; i++) {
if (!gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) return false;
}
return true;
}
/** ถ้า URL บนเซิร์ฟยังไม่มีไฟล์ (404) ให้แผ่นเลื่อนได้ — พื้นผิวนุ่ม ไม่ใส่ข้อความใหญ่ (กันเหมือน debug) */
function ensureGauntletCrownRunwayBgPlaceholdersPlay() {
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return;
const W = 960;
const H = 540;
const hues = [352, 210, 165, 225, 38];
for (let i = 0; i < 5; i++) {
if (gauntletCrownRunwayBgImageSlotReadyPlay(gauntletCrownRunwayBgImgs[i])) continue;
const c = document.createElement('canvas');
c.width = W;
c.height = H;
const g = c.getContext('2d');
const h0 = hues[i];
const grd = g.createLinearGradient(0, 0, W, H * 0.9);
grd.addColorStop(0, `hsl(${h0},26%,20%)`);
grd.addColorStop(0.45, `hsl(${h0},18%,14%)`);
grd.addColorStop(1, `hsl(${h0},12%,10%)`);
g.fillStyle = grd;
g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(255,255,255,0.06)';
g.lineWidth = 1;
for (let x = 0; x < W; x += 48) {
g.beginPath();
g.moveTo(x, 0);
g.lineTo(x + H * 0.04, H);
g.stroke();
}
const vg = g.createRadialGradient(W * 0.45, H * 0.35, 20, W * 0.5, H * 0.55, W * 0.85);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.38)');
g.fillStyle = vg;
g.fillRect(0, 0, W, H);
g.fillStyle = 'rgba(255,255,255,0.1)';
g.font = '10px system-ui,sans-serif';
g.textAlign = 'right';
g.fillText(String(i + 1), W - 10, H - 8);
gauntletCrownRunwayBgImgs[i] = c;
}
}
function gauntletCrownRunwayBgDrawActivePlay() {
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff) return false;
return gauntletCrownRunwayBgImagesReadyPlay();
}
function gauntletCrownRunwayBgHideObstaclesPlay() {
return gauntletCrownRunwayBgDrawActivePlay() && gauntletCrownRunwayBgFinishLatched;
}
/** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */
function gauntletCrownRunwayAvatarRunAllowedPlay() {
if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayBgDrawActivePlay()) {
if (gauntletCrownRunwayBgFinishLatched) return false;
if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) return false;
return true;
}
return true;
}
function drawImageCoverCanvasPlay(ctx, img, cwR, chR) {
const d = gauntletCrownRunwayBgNatDims(img);
if (!d) return;
const iw = d.nw;
const ih = d.nh;
if (!iw || !ih) return;
const scale = Math.max(cwR / iw, chR / ih);
const dw = iw * scale;
const dh = ih * scale;
const dx = (cwR - dw) / 2;
const dy = (chR - dh) / 2;
ctx.drawImage(img, 0, 0, iw, ih, dx, dy, dw, dh);
}
function drawRunwayHSlicePlay(ctx, img, worldX0, segW, scrollLeft, cwR, chR) {
const d0 = gauntletCrownRunwayBgNatDims(img);
if (!d0 || segW <= 0) return;
const natW = d0.nw;
const natH = d0.nh;
const v0 = scrollLeft;
const v1 = scrollLeft + cwR;
const o0 = Math.max(worldX0, v0);
const o1 = Math.min(worldX0 + segW, v1);
if (o1 <= o0) return;
const u0 = ((o0 - worldX0) / segW) * natW;
const u1 = ((o1 - worldX0) / segW) * natW;
const dx = o0 - scrollLeft;
const dw = o1 - o0;
/** ทาบแนวตั้งเล็กน้อยกันช่องว่างย่อย 1px (seam) ระหว่างแถบลูป */
ctx.drawImage(img, u0, 0, u1 - u0, natH, dx - 0.25, 0, dw + 0.55, chR);
}
/** วาดแถบรันเวย์ในระบบพิกัด X = ความกว้างมองเห็น (cwR) × สูงแถบ chR · scrollView = จุดเริ่ม viewport ในโลกรันเวย์ */
function drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView) {
const start = gauntletCrownRunwayBgImgs[0];
const L2 = gauntletCrownRunwayBgImgs[1];
const L3 = gauntletCrownRunwayBgImgs[2];
const L4 = gauntletCrownRunwayBgImgs[3];
const { w0, w1, w2, w3 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
const cycle = w1 + w2 + w3;
const S = scrollView;
ctx.imageSmoothingEnabled = true;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, cwR, chR);
if (w0 > 0) drawRunwayHSlicePlay(ctx, start, 0, w0, S, cwR, chR);
if (cycle <= 0) return;
const ws = [w1, w2, w3];
const ims = [L2, L3, L4];
const uTotal = Math.max(0, S - w0);
let cycN = Math.floor(uTotal / cycle);
let uRem = uTotal - cycN * cycle;
let si = 0;
while (si < 3 && uRem >= ws[si]) {
uRem -= ws[si];
si++;
}
if (si >= 3) {
cycN++;
si = 0;
uRem = 0;
}
let acc = 0;
for (let k = 0; k < si; k++) acc += ws[k];
let curLeft = w0 + cycN * cycle + acc;
let guard = 0;
let idx = si;
while (curLeft < S + cwR && guard++ < 500) {
drawRunwayHSlicePlay(ctx, ims[idx], curLeft, ws[idx], S, cwR, chR);
curLeft += ws[idx];
idx = (idx + 1) % 3;
}
}
/** Last Light mno9kb07: แถบเดียว START → 2 → 3 → 4 → FINISH (ไม่วนลูป) */
function drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView) {
const S = scrollView;
const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(chR);
ctx.imageSmoothingEnabled = true;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, cwR, chR);
const segs = [
{ im: gauntletCrownRunwayBgImgs[0], w: w0, x0: 0 },
{ im: gauntletCrownRunwayBgImgs[1], w: w1, x0: w0 },
{ im: gauntletCrownRunwayBgImgs[2], w: w2, x0: w0 + w1 },
{ im: gauntletCrownRunwayBgImgs[3], w: w3, x0: w0 + w1 + w2 },
{ im: gauntletCrownRunwayBgImgs[4], w: w4, x0: w0 + w1 + w2 + w3 },
];
for (let i = 0; i < segs.length; i++) {
const g = segs[i];
if (g.w > 0 && g.im) drawRunwayHSlicePlay(ctx, g.im, g.x0, g.w, S, cwR, chR);
}
}
/**
* @param {number} canvasW
* @param {number} canvasH
* @param {null|{ worldMinX:number, worldMaxX:number, camX:number, camY:number, zDraw:number, mapWpx:number, mapHpx:number }} worldAlign — ถ้ามี จะคลิป+สเกลให้ตรงกริดแมป (ตัว/สิ่งกีดขวางตรงพื้นที่ตั้ง)
*/
function drawGauntletCrownRunwayBgFullCanvasPlay(canvasW, canvasH, worldAlign) {
const finish = gauntletCrownRunwayBgImgs[4];
const align = worldAlign && typeof worldAlign === 'object'
&& Number.isFinite(worldAlign.worldMinX)
&& Number.isFinite(worldAlign.worldMaxX)
&& Number.isFinite(worldAlign.camX)
&& Number.isFinite(worldAlign.camY)
&& Number.isFinite(worldAlign.zDraw)
&& Number.isFinite(worldAlign.mapWpx)
&& Number.isFinite(worldAlign.mapHpx);
if (gauntletCrownRunwayBgFinishLatched && gauntletCrownRunwayBgImageSlotReadyPlay(finish)) {
if (align) {
const { camX, camY, zDraw, mapWpx, mapHpx } = worldAlign;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.save();
/* เมทริกซ์เดียวกับ worldToScreen + คลิปโลก [0,map] — รูปจบครอบทั้งแผนที่ที่ world origin */
ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
ctx.beginPath();
ctx.rect(0, 0, mapWpx, mapHpx);
ctx.clip();
drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(mapWpx)), Math.max(1, Math.round(mapHpx)));
ctx.restore();
} else {
drawImageCoverCanvasPlay(ctx, finish, Math.max(1, Math.round(canvasW)), Math.max(1, Math.round(canvasH)));
}
return;
}
/* ใช้ความกว้างมองเห็นแบบ float ให้เท่ากับ canvas.width/zDraw — ห้าม Math.round ไม่งั้นซูมแล้วแถบ runway กว้างไม่ตรง worldToScreen (แผนหลุดจากตัว) */
const cwR = align
? Math.max(1e-6, worldAlign.worldMaxX - worldAlign.worldMinX)
: Math.max(1, Math.round(canvasW));
const chR = align
? Math.max(1, Math.round(worldAlign.mapHpx))
: Math.max(1, Math.round(canvasH));
const scrollView = align
? (gauntletCrownRunwayBgScrollPx + worldAlign.worldMinX)
: gauntletCrownRunwayBgScrollPx;
if (align) {
const { camX, camY, zDraw, mapWpx, mapHpx, worldMinX } = worldAlign;
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.save();
ctx.setTransform(zDraw, 0, 0, zDraw, canvasW / 2 - camX * zDraw, canvasH / 2 - camY * zDraw);
ctx.beginPath();
ctx.rect(0, 0, mapWpx, mapHpx);
ctx.clip();
ctx.translate(worldMinX, 0);
if (gauntletCrownRunwayBgMapEligiblePlay()) {
drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
} else {
drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
}
ctx.restore();
return;
}
ctx.save();
if (gauntletCrownRunwayBgMapEligiblePlay()) {
drawGauntletCrownRunwayStripLinearOncePlay(ctx, cwR, chR, scrollView);
} else {
drawGauntletCrownRunwayStripCorePlay(ctx, cwR, chR, scrollView);
}
ctx.restore();
}
/**
* Shared runway strip math (viewport in strip space). null if not drawable.
* Map tuning: gauntletCrownRunwayBg.finishStopScreenFrac 0.08–0.96 = จุดกลางจอต้องถึงภายในแถบ FINISH ก่อนหยุด (ค่าเริ่ม ~0.56 ใกล้กล่องหยุดขวาเส้น FINISH บนงานศิลป์)
*/
function gauntletCrownRunwayScrollGeometryPlay() {
if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null;
const zGaRaw = computePlayCameraZDrawPlay();
const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGaRaw);
if (!gCam) return null;
const halfW = canvas.width / (2 * zGaRaw);
const worldMinX = gCam.px - halfW;
const cwWorld = canvas.width / zGaRaw;
const h = mapData.height || 15;
const ts = mapData.tileSize || 32;
const mapHpx = Math.max(1, Math.round(h * ts));
const { w0, w1, w2, w3, w4 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
const w4Safe = Math.max(w4, 1);
const totalStrip = w0 + w1 + w2 + w3 + w4Safe;
if (!(totalStrip > 8)) return null;
const scrollView = gauntletCrownRunwayBgScrollPx + worldMinX;
const right = scrollView + cwWorld;
const finishStart = w0 + w1 + w2 + w3;
const geoMinScroll = finishStart + Math.max(0, cwWorld - w4Safe);
const wideVpPad = cwWorld > w4Safe * 1.02 ? w4Safe * 0.38 : 0;
const minScrollForFinishPass = geoMinScroll + wideVpPad;
return {
scrollView, right, cwWorld, w4Safe, totalStrip, finishStart, minScrollForFinishPass,
};
}
/** True when viewport center crosses “หยุดที่ FINISH” (ไม่ใช้แค่ขอบขวาถึงปลายแทร็ก) */
function gauntletCrownRunwayScrollStripStopZoneReachedPlay() {
const g = gauntletCrownRunwayScrollGeometryPlay();
if (!g) return false;
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
let frac = Number(raw.finishStopScreenFrac);
if (!Number.isFinite(frac)) frac = 0.56;
frac = Math.max(0.08, Math.min(0.96, frac));
const targetCenter = g.finishStart + g.w4Safe * frac;
const center = g.scrollView + g.cwWorld * 0.5;
if (g.scrollView < g.minScrollForFinishPass - 0.01) return false;
if (center < targetCenter - 1.5) return false;
return true;
}
function resetGauntletCrownRunwaySpawnScrollSnap() {
gauntletCrownRunwaySpawnScrollSnapped = false;
}
/**
* จัด gauntletCrownRunwayBgScrollPx ครั้งแรกให้คอลัมน์เกิด (ซ้ายสุด) ตรง ~runwaySpawnAlignFrac ของความกว้างแถบ START (w0)
* ในแมป: gauntletCrownRunwayBg.runwaySpawnAlignFrac 0.02–0.95 (ค่าเริ่ม 0.32)
* Anchor: คอลัมน์ซ้ายสุดระหว่างช่องเกิดในแมปกับตำแหน่งจริงของทุกคนในรอบ (รวม me + บอทพรีวิว) — ถ้า me ถูก resolve ไปคอลัมน์ซ้ายกว่า min ของช่องวาด แต่ runway ยึดแค่ช่องวาด จะเห็นตัวลอยซ้ายของพรมแดง
* Use min(editor spawn column, floor of every alive entity x) so runway START art stays tied to where characters actually stand.
*/
function trySnapGauntletCrownRunwayScrollToSpawnsPlay() {
if (gauntletCrownRunwaySpawnScrollSnapped) return;
if (!gauntletCrownRunwayBgMapEligiblePlay() || gauntletCrownRunwayBgOff || !mapData) return;
if (!gauntletCrownRunwayBgImagesReadyPlay()) return;
const h = mapData.height || 15;
const ts = mapData.tileSize || 32;
const mapHpx = Math.max(1, Math.round(h * ts));
const { w0 } = gauntletCrownRunwayBgStripWidthsAtCh(mapHpx);
if (!(w0 > 0)) return;
const slots = collectGauntletSpawnSlotsPlay(mapData);
let minTX = Infinity;
if (slots.length) minTX = Math.min(...slots.map((s) => s.x));
if (Number.isFinite(me.x)) minTX = Math.min(minTX, Math.floor(me.x));
others.forEach((o, id) => {
if (!o || o.gauntletEliminated) return;
if (Number.isFinite(o.x)) minTX = Math.min(minTX, Math.floor(o.x));
});
if (!Number.isFinite(minTX) || minTX === Infinity) minTX = 1;
const anchorWorldX = (Math.max(0, minTX) + 0.5) * ts;
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
const frac = Math.max(0.02, Math.min(0.95, Number(raw.runwaySpawnAlignFrac) || 0.32));
gauntletCrownRunwayBgScrollPx = frac * w0 - anchorWorldX;
gauntletCrownRunwaySpawnScrollSnapped = true;
}
function reloadGauntletCrownRunwayBgFromMap() {
gauntletCrownRunwayBgImgs = [null, null, null, null, null];
gauntletCrownRunwayBgScrollPx = 0;
gauntletCrownRunwayBgFinishLatched = false;
gauntletCrownRunwayBgStripFreezeSinceMs = 0;
gauntletCrownRunwayClientMissionShown = false;
lastGauntletCrownRunwayBgTickMs = 0;
resetGauntletCrownRunwaySpawnScrollSnap();
if (!gauntletCrownRunwayBgMapEligiblePlay()) return;
const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {};
gauntletCrownRunwayBgOff = raw.enabled === false;
gauntletCrownRunwayBgSpeedPxPerSec = Math.max(8, Math.min(400, Math.floor(Number(raw.speedPxPerSec)) || 48));
if (gauntletCrownRunwayBgOff) return;
const srcs = [
(typeof raw.startImage === 'string' && raw.startImage.length) ? raw.startImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_START,
(typeof raw.loopImage2 === 'string' && raw.loopImage2.length) ? raw.loopImage2 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP2,
(typeof raw.loopImage3 === 'string' && raw.loopImage3.length) ? raw.loopImage3 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP3,
(typeof raw.loopImage4 === 'string' && raw.loopImage4.length) ? raw.loopImage4 : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_LOOP4,
(typeof raw.finishImage === 'string' && raw.finishImage.length) ? raw.finishImage : GAUNTLET_CROWN_RUNWAY_BG_DEFAULT_FINISH,
];
let pending = 5;
const bump = () => {
pending--;
if (pending <= 0) {
ensureGauntletCrownRunwayBgPlaceholdersPlay();
try { draw(); } catch (e) { /* ignore */ }
}
};
for (let i = 0; i < 5; i++) {
const im = new Image();
const idx = i;
im.onload = () => {
gauntletCrownRunwayBgImgs[idx] = im;
bump();
};
im.onerror = () => { bump(); };
im.src = srcs[i];
}
}
function tickGauntletCrownRunwayBgPlay(dtSec) {
if (!gauntletCrownRunwayBgDrawActivePlay()) return;
/** พรีรัน (howto / countdown) — ห้ามเลื่อนก่อน GO · Mega Virus ใช้ shell เดียวกัน */
if (usesCrownLobbyShellPlay() && gauntletCrownPregamePhase !== 'live') return;
if (gauntletCrownRunwayBgFinishLatched) return;
if (gauntletCrownRunwayBgStripFreezeSinceMs > 0) {
if (Date.now() - gauntletCrownRunwayBgStripFreezeSinceMs >= GAUNTLET_CROWN_RUNWAY_POST_STOP_MS) {
gauntletCrownRunwayBgFinishLatched = true;
gauntletCrownRunwayBgStripFreezeSinceMs = 0;
if (playBotsEnabled() && gauntletCrownRunwayBgMapEligiblePlay() && !gauntletCrownRunwayClientMissionShown) {
gauntletCrownRunwayClientMissionShown = true;
window.setTimeout(function () {
try {
gauntletCrownPregamePhase = null;
gauntletCrownHowtoVisible = false;
const hto = document.getElementById('gauntlet-crown-howto-overlay');
if (hto) hto.classList.add('is-hidden');
const gcc = document.getElementById('gauntlet-crown-countdown');
if (gcc) gcc.classList.add('is-hidden');
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
showGauntletCrownMissionOverlay(gauntletCrownHeistBuildLocalCrownMissionPlay());
} catch (_e) { /* ignore */ }
try {
draw();
} catch (_e2) { /* ignore */ }
}, 340);
}
}
return;
}
if (gauntletCrownRunwayBgMapEligiblePlay() && gauntletCrownRunwayScrollStripStopZoneReachedPlay()) {
gauntletCrownRunwayBgStripFreezeSinceMs = Date.now();
try {
syncGauntletCrownJumpButton();
} catch (_sj) { /* ignore */ }
try {
draw();
} catch (_d) { /* ignore */ }
return;
}
gauntletCrownRunwayBgScrollPx += (gauntletCrownRunwayBgSpeedPxPerSec || 48) * dtSec;
}
/** stack + แมป mnn93hpi — พื้นหลัง intro+loop เลื่อนแนวตั้ง (stackTowerBgScroll ในแมป) */
const STACK_TOWER_SCROLL_BG_DEFAULT_INTRO = BASE + '/img/editor-bg-mnn93hpi/intro.png';
const STACK_TOWER_SCROLL_BG_DEFAULT_LOOP = BASE + '/img/editor-bg-mnn93hpi/loop.png';
let stackTowerScrollBgIntroImg = null;
let stackTowerScrollBgLoopImg = null;
let stackTowerScrollBgPx = 0;
let stackTowerScrollBgSpeedPxPerSec = 40;
let stackTowerScrollBgOff = false;
let stackTowerScrollBgFlowDown = false;
/** Sraw = px+boost+post ครั้งแรกที่คำนวณ world offset — ใช้หา delta แบบคาบ loop (อย่าใช้ fold(Sraw) ลบตรงๆ จะดันโลกขึ้นหลายร้อย px) */
let stackTowerScrollBgSrawWorldBaselinePlay = null;
/** ความสูงวาดของไทล์ loop (พิกเซลจอ) — ใช้ขั้นเลื่อนเมื่อ stepScrollPx = 0 */
let stackTowerScrollBgLoopDrawHPlay = 0;
/** { from, to, t0, dur } — เลื่อนแถบ BG เป็นขั้นตามจำนวนชั้น */
let stackTowerBgScrollStepAnimPlay = null;
let stackTowerBgScrollPendingStepDeltaPlay = 0;
function stackTowerScrollBgMapEligible() {
return !!(mapData && mapData.gameType === 'stack' && (currentPlayMapId() || '').trim() === STACK_TOWER_MISSION_MAP_ID);
}
function stackTowerScrollBgImagesReady() {
if (!stackTowerScrollBgMapEligible()) return false;
const a = stackTowerScrollBgIntroImg;
const b = stackTowerScrollBgLoopImg;
return !!(a && a.complete && a.naturalWidth && b && b.complete && b.naturalWidth);
}
/** แถบ intro+loop เต็มจอ — เปิดเฉพาะเมื่อแมปตั้ง stackTowerBgScroll.enabled: true และรูปโหลดครบ (ไม่โหลด strip เมื่อปิด → ใช้รูปแผนที่ backgroundImage) */
function stackTowerScrollBgDrawActive() {
return stackTowerScrollBgImagesReady() && !stackTowerScrollBgOff;
}
function reloadStackTowerScrollBgFromMap() {
stackTowerScrollBgIntroImg = null;
stackTowerScrollBgLoopImg = null;
stackTowerScrollBgPx = 0;
stackTowerScrollBgSrawWorldBaselinePlay = null;
stackTowerScrollBgLoopDrawHPlay = 0;
stackTowerBgScrollStepAnimPlay = null;
stackTowerBgScrollPendingStepDeltaPlay = 0;
if (!stackTowerScrollBgMapEligible()) return;
const raw = mapData.stackTowerBgScroll && typeof mapData.stackTowerBgScroll === 'object' ? mapData.stackTowerBgScroll : {};
stackTowerScrollBgOff = raw.enabled !== true;
{
const spNum = Number(raw.speedPxPerSec);
stackTowerScrollBgSpeedPxPerSec = Number.isFinite(spNum)
? Math.max(0, Math.min(400, Math.floor(spNum)))
: 0;
}
/* คีย์เวิร์ด down/top = ตัวเลือก "บน→ล่าง" ในเอดิเตอร์ — ใช้สูตรเลื่อนแบบ stripTop−S (ฉากไหลขึ้น) ไม่ใช่ S+chR−… (ไหลลง) เพราะผู้เล่นคาดว่าหอสูง = มองขึ้น */
const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
const rawIsDownKeyword = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom');
stackTowerScrollBgFlowDown = !rawIsDownKeyword;
if (stackTowerScrollBgOff) return;
const introSrc = (typeof raw.introImage === 'string' && raw.introImage.length) ? raw.introImage : STACK_TOWER_SCROLL_BG_DEFAULT_INTRO;
const loopSrc = (typeof raw.loopImage === 'string' && raw.loopImage.length) ? raw.loopImage : STACK_TOWER_SCROLL_BG_DEFAULT_LOOP;
let pending = 2;
const bump = () => {
pending--;
if (pending <= 0) {
const intro0 = stackTowerScrollBgIntroImg;
const loop0 = stackTowerScrollBgLoopImg;
if (loop0 && loop0.complete && intro0 && intro0.complete && canvas && canvas.width) {
const cwR0 = Math.max(1, Math.round(canvas.width));
stackTowerScrollBgLoopDrawHPlay = Math.max(1, Math.round(loop0.naturalHeight * (cwR0 / loop0.naturalWidth)));
}
stackTowerScrollBgSyncInitialScrollToBottom();
try { draw(); } catch (e) { /* ignore */ }
}
};
const im1 = new Image();
im1.onload = () => { stackTowerScrollBgIntroImg = im1; bump(); };
im1.onerror = () => { bump(); };
im1.src = introSrc;
const im2 = new Image();
im2.onload = () => { stackTowerScrollBgLoopImg = im2; bump(); };
im2.onerror = () => { bump(); };
im2.src = loopSrc;
}
function stackTowerScrollBgSyncInitialScrollToBottom() {
const intro = stackTowerScrollBgIntroImg;
if (!canvas || !intro || !intro.complete || !intro.naturalWidth) return;
const cwR = Math.max(1, Math.round(canvas.width));
const chR = Math.max(1, Math.round(canvas.height));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
if (stackTowerScrollBgFlowDown) {
stackTowerScrollBgPx = 0;
} else {
/* ขอบล่าง intro ชิดขอบล่างแคนวาส — คู่กับ stripTop−S */
stackTowerScrollBgPx = drawHIntro - chR;
}
}
/**
* พับค่าเลื่อนแนวตั้ง S (หน่วยเดียวกับ drawStackTowerScrollBgFullCanvas) เข้าช่วงหนึ่งคาบของ loop
* เมื่อ vp0 ≥ drawHIntro — รูปบนจอซ้ำทุก drawHLoop; พับค่า S ก่อนส่งเข้า worldToScreen กัน px โตไม่จำกัดดันโลกหลุดจอ
*/
function foldStackTowerScrollStripViewSForPhase(S, drawHIntro, drawHLoop) {
const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
const s = Number(S) || 0;
if (s < dI) return s;
return s - Math.floor((s - dI) / dH) * dH;
}
/**
* offset แนวตั้งจอให้โลกซิงก์กับ draw ที่ใช้ S = fold(Sraw) — ใช้ความต่างเฟส fold(Sraw)−fold(Sraw0)
* เวลาเลื่อนครบคาบ loop ค่า fold วนกลับ → offset ไม่โตไม่จำกัด · ห้ามคืน fold(Sraw) ล้วนเมื่อ Sraw0 ยังอยู่ intro (baseline ไม่เข้า loop ทำให้สูตร f0+mod ไม่ทำงานและเฟสหลุด)
*/
function stackTowerScrollWorldTotalYFromStripPlay(Sraw, drawHIntro, drawHLoop, Sraw0) {
if (Sraw0 == null) return 0;
const dH = Math.max(1, Math.round(Number(drawHLoop) || 1));
const dI = Math.max(0, Math.round(Number(drawHIntro) || 0));
const s = Number(Sraw) || 0;
const s0 = Number(Sraw0) || 0;
const fs = foldStackTowerScrollStripViewSForPhase(s, dI, dH);
const fs0 = foldStackTowerScrollStripViewSForPhase(s0, dI, dH);
return fs - fs0;
}
/** zDrawPan ส่งต่อเพื่อคูณ boost กับ z — ไม่ scale แคนวาสทั้งแผง (เคย scale รอบกลางทำให้จอบางส่วนว่างเมื่อ z ≠ zRef) */
function drawStackTowerScrollBgFullCanvas(cw, ch, zDrawPan) {
const intro = stackTowerScrollBgIntroImg;
const loop = stackTowerScrollBgLoopImg;
if (!intro || !intro.complete || !loop || !loop.complete) return;
const zPan = Number(zDrawPan) > 0 ? zDrawPan : zoom;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleI = cwR / intro.naturalWidth;
const drawHIntro = Math.round(intro.naturalHeight * scaleI);
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const camFollowScreen = isStackTowerMissionUiMapPlay() ? getStackTowerBgScrollHeightBoostPx() * zPan : 0;
const post50Scroll = isStackTowerMissionUiMapPlay() ? getStackTowerPost50BgScrollExtraPxPlay(chR) : 0;
const Sraw = stackTowerScrollBgPx + camFollowScreen + post50Scroll;
const S = foldStackTowerScrollStripViewSForPhase(Sraw, drawHIntro, drawHLoop);
const vp0 = S;
const vp1 = S + chR;
const yLimit = vp1 + drawHLoop * 96;
const flowDown = stackTowerScrollBgFlowDown;
function stripTopToDestY(stripTop, drawH) {
if (!flowDown) return stripTop - S;
return S + chR - (stripTop + drawH);
}
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.beginPath();
ctx.rect(0, 0, cwR, chR);
ctx.clip();
if (vp0 < 0) {
let k = 1;
if (vp0 < -drawHLoop * 4) {
k = Math.max(1, Math.floor(-vp0 / drawHLoop) - 2);
}
for (; k < 50000; k++) {
const y0 = -k * drawHLoop;
if (y0 >= vp1 + drawHLoop) break;
if (y0 + drawHLoop <= vp0) continue;
const dy = Math.round(stripTopToDestY(y0, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
}
}
if (drawHIntro > vp0 && vp1 > 0) {
const dy = Math.round(stripTopToDestY(0, drawHIntro));
ctx.drawImage(intro, 0, 0, intro.naturalWidth, intro.naturalHeight, 0, dy, cwR, drawHIntro);
}
let y = drawHIntro;
if (y < yLimit && vp1 > drawHIntro) {
if (vp0 > drawHIntro) {
const n = Math.floor((vp0 - drawHIntro) / drawHLoop);
/* ใช้ n ไม่ใช่ n−1 — เดิมทำให้แถบ loop เริ่มสูงกว่า vp0 หนึ่งความสูง จอล่างว่างดำเมื่อ S ใหญ่ */
y = drawHIntro + n * drawHLoop;
}
while (y < yLimit) {
const dy = Math.round(stripTopToDestY(y, drawHLoop));
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, dy, cwR, drawHLoop);
y += drawHLoop;
}
}
ctx.restore();
}
/** กล้องมองเหนือขอบบนแมป (worldMinY<0) + ใช้รูปแผนที่ — ยัด loop ท้องฟ้าเติมครึ่งบนจอ */
function drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, cw, ch) {
if (!isStackTowerMissionUiMapPlay() || worldMinY >= 0) return;
const loop = stackTowerScrollBgLoopImg;
if (!loop || !loop.complete || !loop.naturalWidth) return;
const cwR = Math.max(1, Math.round(cw));
const chR = Math.max(1, Math.round(ch));
const scaleL = cwR / loop.naturalWidth;
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * scaleL));
const skyScreenH = Math.min(chR, Math.ceil(-worldMinY * zDraw) + 4);
const stripPx = stackTowerScrollBgOff ? 0 : stackTowerScrollBgPx;
const rawScroll = stripPx + getStackTowerBgScrollHeightBoostPx() * zDraw
+ getStackTowerPost50BgScrollExtraPxPlay(chR);
const scroll = ((rawScroll % drawHLoop) + drawHLoop) % drawHLoop;
let y = -scroll;
ctx.save();
ctx.imageSmoothingEnabled = false;
while (y < skyScreenH + drawHLoop) {
if (y + drawHLoop > 0) {
ctx.drawImage(loop, 0, 0, loop.naturalWidth, loop.naturalHeight, 0, y, cwR, drawHLoop);
}
y += drawHLoop;
}
ctx.restore();
}
function tickStackTowerBgScrollStepAnimPlay() {
const q = stackTowerBgScrollStepAnimPlay;
if (!q) return;
const t = Math.min(1, (performance.now() - q.t0) / Math.max(1, q.dur));
const e = 1 - Math.pow(1 - t, 3);
stackTowerScrollBgPx = q.from + (q.to - q.from) * e;
if (t >= 1) {
stackTowerScrollBgPx = q.to;
stackTowerBgScrollStepAnimPlay = null;
stackTowerScrollBgSrawWorldBaselinePlay = null;
if (stackTowerBgScrollPendingStepDeltaPlay !== 0) {
const d = stackTowerBgScrollPendingStepDeltaPlay;
stackTowerBgScrollPendingStepDeltaPlay = 0;
startStackTowerBgScrollStepAnimPlay(d);
}
}
}
function getStackTowerBgScrollStepEveryLayersPlay() {
const raw = mapData && mapData.stackTowerBgScroll;
if (!raw || typeof raw !== 'object') return 0;
const n = Math.floor(Number(raw.stepEveryLayers));
return Number.isFinite(n) && n > 0 ? Math.min(200, n) : 0;
}
function getStackTowerBgScrollStepAnimMsPlay() {
const raw = mapData && mapData.stackTowerBgScroll;
if (!raw || typeof raw !== 'object') return 520;
const t = Math.floor(Number(raw.stepAnimMs));
return Number.isFinite(t) ? Math.max(120, Math.min(4000, t)) : 520;
}
function getStackTowerBgScrollStepScrollPxPlay() {
const raw = mapData && mapData.stackTowerBgScroll;
if (!raw || typeof raw !== 'object') return 0;
const d = Math.floor(Number(raw.stepScrollPx));
return Number.isFinite(d) ? Math.max(0, Math.min(8000, d)) : 0;
}
function startStackTowerBgScrollStepAnimPlay(deltaPx) {
if (!stackTowerScrollBgDrawActive() || !deltaPx) return;
if (stackTowerBgScrollStepAnimPlay) {
stackTowerBgScrollPendingStepDeltaPlay += deltaPx;
return;
}
const dur = getStackTowerBgScrollStepAnimMsPlay();
stackTowerBgScrollStepAnimPlay = {
from: stackTowerScrollBgPx,
to: stackTowerScrollBgPx + deltaPx,
t0: performance.now(),
dur,
};
}
function maybeQueueStackTowerBgScrollStepPlay(layerCountAfter) {
if (!stackTowerScrollBgDrawActive()) return;
const every = getStackTowerBgScrollStepEveryLayersPlay();
if (every <= 0 || layerCountAfter <= 0 || layerCountAfter % every !== 0) return;
let delta = getStackTowerBgScrollStepScrollPxPlay();
if (!delta) delta = Math.max(8, stackTowerScrollBgLoopDrawHPlay || 200);
delta = Math.min(8000, Math.max(8, delta));
startStackTowerBgScrollStepAnimPlay(delta);
}
function getStackTowerStripReleaseGapWorldPxPlay() {
const raw = mapData && mapData.stackTowerBgScroll;
if (!isStackTowerMissionUiMapPlay() || !raw || typeof raw !== 'object') return null;
const g = Number(raw.releaseGapWorldPx);
if (!Number.isFinite(g) || g < 0) return null;
return Math.min(800, g);
}
function updateStackTowerSwingYForStripGapPlay() {
if (!stackMini || !isStackTowerMissionUiMapPlay()) return;
const gap = getStackTowerStripReleaseGapWorldPxPlay();
if (gap == null) return;
const m = stackMini;
const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
const nLay = m.layers ? m.layers.length : 0;
const topBlockTopY = m.floorWorldY - nLay * lh;
const swingLift = getStackTowerSwingLiftWorldPx(nLay, lh);
m.swingWorldY = topBlockTopY - gap + swingLift;
}
function tickStackTowerScrollBg(dtSec) {
if (!stackTowerScrollBgMapEligible() || stackTowerScrollBgOff) return;
if (!stackTowerScrollBgImagesReady()) return;
tickStackTowerBgScrollStepAnimPlay();
if (stackTowerBgScrollStepAnimPlay) return;
const spd = Math.max(0, Number(stackTowerScrollBgSpeedPxPerSec) || 0) * dtSec;
if (spd <= 0) return;
/* !flowDown (stripTop−S): S เพิ่ม → dy ลด → ฉากไหลขึ้น — ห้ามใช้ −= (จะลด S แล้วศิลป์ไหลลง) */
stackTowerScrollBgPx += spd;
}
/**
* ค่าลบจาก screen Y ใน worldToScreen — ซิงก์กับ drawStackTowerScrollBgFullCanvas (auto + boost + post-50)
* แถบ BG วาด 1:1 กับแคนวาส — ห้ามใช้ fold(Sraw) เป็นค่าลบตรงๆ (จะดันโลกขึ้นหลายร้อย px เหมือนครึ่งล่างจอว่าง)
*/
function getStackTowerWorldLayerScrollScreenOffsetYPlay(zPan) {
if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
const zPn = Number(zPan) > 0 ? zPan : zoom;
const chPx = Math.max(1, Math.round(canvas && canvas.height ? canvas.height : 720));
const boostScreen = getStackTowerBgScrollHeightBoostPx() * zPn;
const post50Screen = getStackTowerPost50BgScrollExtraPxPlay(chPx);
const intro = stackTowerScrollBgIntroImg;
const loop = stackTowerScrollBgLoopImg;
let totalScreen = boostScreen + post50Screen;
if (stackTowerScrollBgDrawActive() && intro && intro.complete && intro.naturalWidth
&& loop && loop.complete && loop.naturalWidth && canvas && canvas.width) {
const cwR = Math.max(1, Math.round(canvas.width));
const drawHIntro = Math.round(intro.naturalHeight * (cwR / intro.naturalWidth));
const drawHLoop = Math.max(1, Math.round(loop.naturalHeight * (cwR / loop.naturalWidth)));
const SrawDraw = stackTowerScrollBgPx + boostScreen + post50Screen;
if (stackTowerScrollBgSrawWorldBaselinePlay == null) stackTowerScrollBgSrawWorldBaselinePlay = SrawDraw;
const S0 = stackTowerScrollBgSrawWorldBaselinePlay;
let stripY;
if (stackTowerScrollBgFlowDown) {
const Sview = foldStackTowerScrollStripViewSForPhase(SrawDraw, drawHIntro, drawHLoop);
stripY = (boostScreen + post50Screen) * 2 - Sview;
} else {
stripY = stackTowerScrollWorldTotalYFromStripPlay(SrawDraw, drawHIntro, drawHLoop, S0);
}
totalScreen = stripY;
} else if (stackTowerScrollBgDrawActive()) {
const px = stackTowerScrollBgFlowDown ? -stackTowerScrollBgPx : stackTowerScrollBgPx;
totalScreen = px + boostScreen + post50Screen;
}
return totalScreen;
}
/** โหลดจาก mapData.gridImageLibrary — ดัชนีตรงกับ gridImageCells[y][x] */
let mapGridImageImgs = [];
let mapGridImageHeldImgs = [];
let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, gauntletEliminated: false, tx: null, ty: null, quizCarryHeld: null, gauntletCrownPenaltyFxUntil: 0 };
const others = new Map();
/** Stack preview HUD: เทอร์มินัลล็อก (cyber UI) */
const STACK_PREVIEW_HUD_LOG_MAX = 8;
let stackPreviewHudLog = [];
function stackPreviewPushHudLog(line) {
if (!playBotsEnabled()) return;
stackPreviewHudLog.push(String(line || '').slice(0, 96));
while (stackPreviewHudLog.length > STACK_PREVIEW_HUD_LOG_MAX) stackPreviewHudLog.shift();
}
function stackPreviewLogStackDrop(hit, actor) {
if (!playBotsEnabled() || !hit) return;
const bid = actor && actor.botId;
let nm = 'NODE';
if (actor && actor.human) nm = (me.nickname || 'YOU').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
else if (bid && others.get(bid)) nm = String(others.get(bid).nickname || 'BOT').toUpperCase().replace(/\s+/g, '_').slice(0, 12);
if (hit.miss) stackPreviewPushHudLog(`>>> ${nm}: SECTOR_MISS`);
else if (hit.perfect) stackPreviewPushHudLog(`>>> ${nm}: PERFECT +${hit.pts} SYN`);
else stackPreviewPushHudLog(`>>> ${nm}: LOCK +${hit.pts}`);
}
const keys = {};
const characterImages = {};
/** data URL จาก canvas compose สี — กันยิง toDataURL ทุกเฟรมตอน sync HUD */
const cyberHudScoreAvatarUrlCache = new Map();
const CYBER_HUD_AV_URL_CACHE_CAP = 56;
function createDefaultAvatarImg() {
const c = document.createElement('canvas');
c.width = 64; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = '#7aa2f7';
ctx.beginPath();
ctx.arc(32, 22, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#9ece6a';
ctx.beginPath();
ctx.arc(32, 48, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1b26';
ctx.beginPath();
ctx.arc(28, 20, 3, 0, Math.PI * 2);
ctx.arc(36, 20, 3, 0, Math.PI * 2);
ctx.fill();
const img = new Image();
img.src = c.toDataURL('image/png');
return img;
}
const defaultAvatarImg = createDefaultAvatarImg();
const characterAnimations = {};
const characterIdleAnimations = {};
const CHARACTER_ANIM_FRAMES = 4;
const CHARACTER_ANIM_FRAME_MS = 200;
/** จังหวะเดินลูปคงที่ 4 เฟรม — อย่า modulo ด้วยจำนวนเฟรมที่โหลดได้แล้ว (ไม่งั้นเฟรมกลางหาย/ลูปสั้นลง) */
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', '#fec1fe'];
const PLAY_TINT_BODY = ['#d72520', '#ef8508', '#efe237', '#5bb443', '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe'];
const playLayerMode = {};
/** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */
const playLayerAllDirsQueued = {};
/** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก
อย่างเดียว) */
let playCharLayerApi = { status: 'idle', map: null };
/** id → Set เลเยอร์ที่มีไฟล์จริง (จาก GET /api/characters) — กันยิง URL เลเยอร์ที่ไม่เคยอัปโหลด → 404 รกคอนโซล */
const playCharDiskLayersById = Object.create(null);
function ensurePlayCharLayerListFetch() {
if (playCharLayerApi.status !== 'idle') return;
playCharLayerApi.status = 'loading';
fetch(BASE + '/api/characters')
.then((r) => r.json())
.then((list) => {
const map = Object.create(null);
playCharacterIdRoster = [];
if (Array.isArray(list)) {
list.forEach((c) => {
if (!c || !c.id) return;
playCharacterIdRoster.push(String(c.id));
map[c.id] = !!c.hasLayerFiles;
if (Array.isArray(c.layers) && c.layers.length) {
const st = new Set();
c.layers.forEach((n) => {
if (typeof n === 'string' && n.length) st.add(n);
});
if (st.size) playCharDiskLayersById[c.id] = st;
else delete playCharDiskLayersById[c.id];
} else {
delete playCharDiskLayersById[c.id];
}
});
}
playCharLayerApi = { status: 'done', map };
})
.catch(() => {
playCharacterIdRoster = [];
playCharLayerApi = { status: 'done', map: Object.create(null) };
});
}
/** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */
function playCharLayerFromApi(characterId) {
if (playCharLayerApi.status !== 'done' || !characterId) return null;
if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) {
return playCharLayerApi.map[characterId];
}
return null;
}
/** ถ้าเซิร์ฟเวอร์ส่งรายชื่อเลเยอร์ที่มีบนดิสก์ — ไม่โหลดเลเยอร์อื่น (ลด 404) */
function playCharDiskLayersSet(characterId) {
if (!characterId || playCharLayerApi.status !== 'done') return null;
const s = playCharDiskLayersById[characterId];
return s && s.size ? s : null;
}
const playLayerImageCache = new Map();
const playLayerCompositeCache = new Map();
function pickRandomPlayTint() {
return {
head: PLAY_TINT_HEAD[Math.floor(Math.random() * PLAY_TINT_HEAD.length)],
hair: PLAY_TINT_HAIR[Math.floor(Math.random() * PLAY_TINT_HAIR.length)],
body: PLAY_TINT_BODY[Math.floor(Math.random() * PLAY_TINT_BODY.length)],
};
}
/** FNV-1a 32-bit — แยกดัชนี head/hair/body ได้กระจายกว่า hash*31 เดิม (กันบอท __pv_bot_N สีซ้ำ) */
function hashStringFnv1a32(str) {
let h = 2166136261 >>> 0;
const s = String(str || '');
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
function playTintFromPeerId(id) {
const s = String(id || 'x');
const h0 = hashStringFnv1a32(s);
const h1 = hashStringFnv1a32(s + '\n1hair');
const h2 = hashStringFnv1a32(s + '\n2body');
return {
head: PLAY_TINT_HEAD[h0 % PLAY_TINT_HEAD.length],
hair: PLAY_TINT_HAIR[h1 % PLAY_TINT_HAIR.length],
body: PLAY_TINT_BODY[h2 % PLAY_TINT_BODY.length],
};
}
const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint';
function lobbyBotTintsStorageKeyPlay() {
const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default');
return 'justiceLobbyBotTints:' + sp;
}
function rgbCssToHexPlay(rgb) {
if (!rgb) return '';
const s = String(rgb).trim();
if (s.charAt(0) === '#') return s;
const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if (!m) return '';
return '#' + [m[1], m[2], m[3]].map((n) => {
const v = Math.max(0, Math.min(255, parseInt(n, 10)));
return v.toString(16).padStart(2, '0');
}).join('');
}
function playTintFromLobbyRgb(themeRgb, skinRgb) {
const bodyHex = rgbCssToHexPlay(themeRgb);
const headHex = rgbCssToHexPlay(skinRgb);
if (!headHex && !bodyHex) return null;
return {
head: headHex || '#fbd5c4',
hair: bodyHex || '#fb4941',
body: bodyHex || '#fb4941',
};
}
function loadPlayTintFromLobbyPlayerStorage() {
try {
const raw = localStorage.getItem(LOBBY_PLAYER_TINT_KEY);
if (raw) {
const j = JSON.parse(raw);
if (j && typeof j === 'object') {
if (j.head && j.body) {
return {
head: String(j.head),
hair: String(j.hair || j.body),
body: String(j.body),
};
}
const fromJson = playTintFromLobbyRgb(j.themeRgb, j.skinRgb);
if (fromJson) return fromJson;
}
}
} catch (e) { /* ignore */ }
try {
const theme = localStorage.getItem('lobbyThemeColor') || '';
const skin = localStorage.getItem('lobbySkinTone') || '';
return playTintFromLobbyRgb(theme, skin);
} catch (e2) {
return null;
}
}
function loadPlayTintForBotPeerId(peerId) {
const sid = String(peerId || '');
let botKey = sid;
if (sid.indexOf(PREVIEW_BOT_PREFIX) === 0) {
botKey = '__lobby_bot_' + sid.slice(PREVIEW_BOT_PREFIX.length);
}
try {
const raw = sessionStorage.getItem(lobbyBotTintsStorageKeyPlay());
if (!raw) return null;
const map = JSON.parse(raw);
const saved = map && map[botKey];
if (!saved) return null;
return playTintFromLobbyRgb(saved.colorTheme, saved.colorSkin);
} catch (e) {
return null;
}
}
/** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */
function resolvePlayTintForPeer(peerId) {
const sid = String(peerId || '');
if (myId != null && sid === String(myId)) {
const meTint = loadPlayTintFromLobbyPlayerStorage();
if (meTint) return meTint;
}
if ((sid === '__me' || sid === '__local_me') && me && me.playTint) return me.playTint;
const botTint = loadPlayTintForBotPeerId(sid);
if (botTint) return botTint;
return playTintFromPeerId(sid);
}
/** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */
function missionEnsureHumanInTopRanked(rows, maxN) {
const cap = Math.max(1, maxN || 5);
if (!Array.isArray(rows) || !rows.length) return [];
const humanId = myId != null ? String(myId) : null;
const humanRow = humanId
? rows.find((r) => r && r.id != null && String(r.id) === humanId)
: rows.find((r) => r && r.isLocalHuman);
let top = rows.slice(0, cap);
if (!humanRow) return top;
const hid = humanRow.id != null ? String(humanRow.id) : '';
if (top.some((r) => r && r.id != null && String(r.id) === hid)) return top;
if (top.length >= cap) top = top.slice(0, cap - 1);
top.push(humanRow);
return top;
}
function hexToRgb01(hex) {
const h = (hex || '').replace('#', '').trim();
if (h.length !== 6) return [1, 1, 1];
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function tintPlayLayerImageData(imageData, tintHex) {
const rgb = hexToRgb01(tintHex);
const tr = rgb[0], tg = rgb[1], tb = rgb[2];
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] < 12) continue;
const r = d[i], g = d[i + 1], b = d[i + 2];
const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
d[i] = Math.min(255, Math.round(tr * 255 * L));
d[i + 1] = Math.min(255, Math.round(tg * 255 * L));
d[i + 2] = Math.min(255, Math.round(tb * 255 * L));
}
}
function drawPlayTintedLayer(ctx, w, h, img, tintHex) {
if (!tintHex) {
ctx.drawImage(img, 0, 0, w, h);
return;
}
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
x.drawImage(img, 0, 0, w, h);
try {
const idata = x.getImageData(0, 0, w, h);
tintPlayLayerImageData(idata, tintHex);
x.putImageData(idata, 0, 0);
} catch (e) {
x.clearRect(0, 0, w, h);
x.drawImage(img, 0, 0, w, h);
}
ctx.drawImage(c, 0, 0, w, h);
}
function ensurePlayLayerImage(url) {
let img = playLayerImageCache.get(url);
if (!img) {
img = new Image();
img.src = url;
playLayerImageCache.set(url, img);
}
return img;
}
/** อุ่น cache เลเยอร์สีของตัวละคร (ทุกทิศ + เดิน 0-3 + idle) ก่อนเข้าเกม กันกระตุกตอนโหลดสีกลางเกม
* ยิงเฉพาะ layer ที่ "มีจริง" (อ่านจาก manifest/disk set) เพื่อกัน 404 รัว — รอ manifest พร้อมก่อน */
const _playPreloadedChars = new Set();
const _playPreloadedTintKeys = new Set();
function preloadPlayCharacterLayers(charId, _tries) {
if (!charId || _playPreloadedChars.has(charId)) return;
try { ensurePlayCharLayerListFetch(); } catch (e) { /* ignore */ }
try { ensurePlayLayerProbesAllDirections(charId); } catch (e) { /* ignore */ }
const diskSet = playCharDiskLayersSet(charId);
if (!diskSet) {
// manifest ยังไม่พร้อม — ลองใหม่อีกครู่ (กันยิง layer ที่ไม่มี = 404)
const t = _tries || 0;
if (t < 8) setTimeout(function () { preloadPlayCharacterLayers(charId, t + 1); }, 450);
return;
}
_playPreloadedChars.add(charId);
const dirs = ['down', 'up', 'left', 'right'];
const warm = function (urls) { if (urls && urls.length) ensurePlayLayerImage(urls[0]); };
dirs.forEach(function (dir) {
PLAY_LAYER_ORDER.forEach(function (ln) {
if (ln !== 'shadow' && !diskSet.has(ln)) return; // ข้าม layer ที่ไม่มีไฟล์
if (ln === 'face' && dir === 'up' && isUploadedCharAssetId(charId)) return; // up ไม่มี face
for (let fi = 0; fi < CHARACTER_ANIM_FRAMES; fi++) {
warm(ln === 'shadow' ? shadowUrlCandidates(charId, dir, fi) : layerUrlCandidates(charId, dir, ln, fi));
}
warm(ln === 'shadow' ? shadowUrlCandidatesIdle(charId, dir, 0) : layerUrlCandidatesIdle(charId, dir, ln, 0));
});
});
}
/** อุ่น canvas ย้อมสี (head/hair/body) ต่อตัวละคร+สี */
function preloadPlayTintForAvatar(charId, tint) {
if (!charId || !tint || typeof tint.head !== 'string') return;
const key = [charId, tint.head, tint.hair, tint.body].join('|');
if (_playPreloadedTintKeys.has(key)) return;
_playPreloadedTintKeys.add(key);
preloadPlayCharacterLayers(charId);
const dirs = ['down', 'up', 'left', 'right'];
const now = Date.now();
dirs.forEach(function (dir) {
const rawWalk = getCharacterFrame(charId, dir, now, true);
const rawIdle = getCharacterFrame(charId, dir, now, false);
if (rawWalk) getPlayTintedAvatarSource(rawWalk, charId, dir, now, true, tint);
if (rawIdle) getPlayTintedAvatarSource(rawIdle, charId, dir, now + 400, false, tint);
});
}
function preloadPlayOccupantsTints() {
const meCid = me.characterId || getPlayCharacterId();
if (meCid) {
preloadPlayTintForAvatar(meCid, me.playTint || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))));
}
others.forEach(function (o, id) {
if (!o || !o.characterId) return;
preloadPlayTintForAvatar(o.characterId, o.playTint || resolvePlayTintForPeer(id));
});
}
/** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */
function layerUrlCandidates(id, dir, layerName, frameIndex) {
const enc = encodeURIComponent(id);
const base = BASE + '/img/characters/' + enc + '_' + dir;
const out = [];
function add(u) {
if (out.indexOf(u) === -1) out.push(u);
}
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
add(base + '_layer_' + layerName + '.png');
return out;
}
/** เลเยอร์ idle: id_
_idle__layer_.png หรือ id__idle_layer_.png */
function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) {
const enc = encodeURIComponent(id);
const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle';
const out = [];
function add(u) {
if (out.indexOf(u) === -1) out.push(u);
}
// idle เฟรมเดียว — ชื่อจริงของ char-* คือ *_idle_layer_*.png (ไม่มี frame index) ลองตัวนี้ก่อนกัน 404 รัว
add(base + '_layer_' + layerName + '.png');
add(base + '_' + frameIndex + '_layer_' + layerName + '.png');
if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png');
return out;
}
function shadowUrlCandidates(id, dir, frameIndex) {
const c = layerUrlCandidates(id, dir, 'shadow', frameIndex);
const def = BASE + '/img/default-shadow-' + dir + '.png';
if (c.indexOf(def) === -1) c.push(def);
return c;
}
function shadowUrlCandidatesIdle(id, dir, frameIndex) {
const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex);
const def = BASE + '/img/default-shadow-' + dir + '.png';
if (c.indexOf(def) === -1) c.push(def);
return c;
}
/** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */
function resolvePlayLayerImage(urls) {
for (let i = 0; i < urls.length; i++) {
const img = ensurePlayLayerImage(urls[i]);
if (!img.complete) return { status: 'pending' };
if (img.naturalWidth > 0) return { status: 'ok', img };
}
return { status: 'missing' };
}
function ensurePlayLayerProbesAllDirections(characterId) {
if (!characterId || playLayerAllDirsQueued[characterId]) return;
playLayerAllDirsQueued[characterId] = true;
['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d));
}
function playCharAnyDirectionLayered(characterId) {
if (!characterId) return false;
return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered');
}
/** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */
function playCharLayerDiscoveryPending(characterId) {
if (!characterId) return true;
const api = playCharLayerFromApi(characterId);
if (api === true || api === false) return false;
if (playCharAnyDirectionLayered(characterId)) return false;
return ['up', 'down', 'left', 'right'].some((d) => {
const m = playLayerMode[characterId + '|' + d];
return m === undefined || m === 'pending';
});
}
function ensurePlayLayerProbe(characterId, dir) {
const key = characterId + '|' + dir;
if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return;
if (playLayerMode[key] === 'pending') return;
playLayerMode[key] = 'pending';
const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0);
let ci = 0;
function tryNextProbe() {
if (playLayerMode[key] !== 'pending') return;
if (ci >= cands.length) {
playLayerMode[key] = 'none';
return;
}
const url = cands[ci++];
const img = ensurePlayLayerImage(url);
const fin = () => {
if (playLayerMode[key] !== 'pending') return;
if (img.naturalWidth > 0) {
playLayerMode[key] = 'layered';
return;
}
tryNextProbe();
};
img.onload = fin;
img.onerror = fin;
if (img.complete) fin();
}
tryNextProbe();
}
function getCharacterAnimFrameIndex(id, dir, now, isWalking) {
if (!id) return 0;
const key = id + '_' + dir;
const anim = characterAnimations[key];
if (!anim) return 0;
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
return fi >= 0 ? fi : 0;
}
function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) {
const useIdle = opts && opts.idle;
if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null;
const w = rawImg.naturalWidth, h = rawImg.naturalHeight;
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const cctx = c.getContext('2d');
let anyTintColorLayer = false;
let skippedShadowWhilePending = false;
const diskLayers = playCharDiskLayersSet(id);
/* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */
const idleLayerStripes = !!useIdle;
const resDir = dir;
const resFi = frameIndex;
for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) {
const layerName = PLAY_LAYER_ORDER[li];
if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue;
/* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */
if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') {
continue;
}
let urls;
if (layerName === 'shadow') {
urls = idleLayerStripes
? shadowUrlCandidatesIdle(id, dir, frameIndex)
: shadowUrlCandidates(id, resDir, resFi);
} else {
urls = idleLayerStripes
? layerUrlCandidatesIdle(id, dir, layerName, frameIndex)
: layerUrlCandidates(id, resDir, layerName, resFi);
}
const r = resolvePlayLayerImage(urls);
/* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */
if (r.status === 'pending' && layerName === 'shadow') {
skippedShadowWhilePending = true;
continue;
}
if (r.status === 'pending') return null;
if (r.status === 'missing') continue;
const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null;
if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true;
drawPlayTintedLayer(cctx, w, h, r.img, tintHex);
}
/* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */
if (!anyTintColorLayer) return null;
return { canvas: c, skipCache: skippedShadowWhilePending };
}
function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) {
if (!tint || !characterId || !rawImg) return rawImg;
ensurePlayCharLayerListFetch();
ensurePlayLayerProbesAllDirections(characterId);
const dirn = dir || 'down';
const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking);
const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now());
if (playCharLayerDiscoveryPending(characterId)) return rawImg;
const apiFlag = playCharLayerFromApi(characterId);
/* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */
const charHasLayerFiles = apiFlag === true
|| (apiFlag == null && playCharAnyDirectionLayered(characterId));
/* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */
if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) {
return rawImg;
}
const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|');
if (charHasLayerFiles) {
if (!isWalking) {
const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase;
const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|');
const hitI = playLayerCompositeCache.get(cacheKeyIdle);
if (hitI) return hitI;
const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true });
if (packI && packI.canvas) {
if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas);
return packI.canvas;
}
}
const hit = playLayerCompositeCache.get(cacheKeyWalk);
if (hit) return hit;
const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false });
if (pack && pack.canvas) {
if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas);
return pack.canvas;
}
return rawImg;
}
/*
* ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม)
*/
return rawImg;
}
function getCharacterImg(id, direction) {
if (!id) return null;
const key = id + '_' + (direction || 'down');
if (characterImages[key]) return characterImages[key];
const img = new Image();
img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png';
characterImages[key] = img;
return img;
}
function getCharacterFrame(id, direction, now, isWalking) {
if (!id) return null;
const dir = direction || 'down';
const t = typeof now === 'number' ? now : Date.now();
if (!isWalking) {
const idleAnim = ensureCharacterIdleAnim(id, dir);
let idlePhase = idleAnimPhaseIndex(t);
if (isUploadedCharAssetId(id)) idlePhase = 0;
const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase);
if (idleFi >= 0) return idleAnim.frames[idleFi];
const idfb = idleAnim.fallback;
if (idfb && idfb.complete && idfb.naturalWidth) return idfb;
}
const key = id + '_' + dir;
let anim = characterAnimations[key];
if (!anim) {
anim = { frames: [], fallback: null };
characterAnimations[key] = anim;
for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) {
const img = new Image();
img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png';
anim.frames.push(img);
}
anim.fallback = getCharacterImg(id, dir);
}
const phase = walkAnimPhaseIndex(now, isWalking);
const fi = pickLoadedWalkFrameIndex(anim, phase);
if (fi >= 0) return anim.frames[fi];
const fb = anim.fallback;
if (fb && fb.complete && fb.naturalWidth) return fb;
return null;
}
function getAvatarImg(characterId, direction, now, isWalking) {
const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null;
if (img) return img;
return defaultAvatarImg;
}
/** composite idle ลงหน้า — เดียวกับ #room-lobby-profile-avatar ในล็อบบี้ */
const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:';
const PLAY_LOBBY_AVATAR_SS_PREFIX = 'justicePlayLobbyAvatar:';
const playLobbyIdleAvatarCache = new Map();
function playLobbyIdleAvatarSrcForCharacter(characterId) {
const id = String(characterId || '').trim();
if (!id) return '';
try {
const ss = sessionStorage.getItem(PLAY_LOBBY_AVATAR_SS_PREFIX + id) || '';
if (ss && ss.indexOf('data:image/') === 0) return ss;
const ls = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id) || '';
if (ls && ls.indexOf('data:image/') === 0) return ls;
} catch (e) { /* ignore */ }
return '';
}
function ensurePlayLobbyIdleAvatarImage(characterId) {
const id = String(characterId || '').trim();
if (!id) return;
const hit = playLobbyIdleAvatarCache.get(id);
if (hit && hit.complete && hit.naturalWidth) return;
if (playLobbyIdleAvatarCache.get(id + '_loading')) return;
const src = playLobbyIdleAvatarSrcForCharacter(id);
if (!src) return;
const img = new Image();
img.onload = function () {
playLobbyIdleAvatarCache.set(id, img);
playLobbyIdleAvatarCache.delete(id + '_loading');
};
img.onerror = function () {
playLobbyIdleAvatarCache.delete(id + '_loading');
};
img.src = src;
playLobbyIdleAvatarCache.set(id + '_loading', img);
}
function getPlayLobbyIdleAvatarImageSync(characterId) {
const id = String(characterId || '').trim();
if (!id) return null;
const hit = playLobbyIdleAvatarCache.get(id);
if (hit && hit.complete && hit.naturalWidth) return hit;
ensurePlayLobbyIdleAvatarImage(id);
return null;
}
/** วาดตัวละคร — ทิศลง + useLobbyIdleComposite: composite เดียวกับล็อบบี้ (เฉพาะผู้เล่นจริง); คนอื่นใช้เลเยอร์+สีตาม peer */
function resolvePlayDrawCharSource(characterId, direction, now, isWalking, playTint, useLobbyIdleComposite) {
const dir = direction || 'down';
const walk = !!isWalking;
if (dir === 'down' && useLobbyIdleComposite) {
const lobbyImg = getPlayLobbyIdleAvatarImageSync(characterId);
if (lobbyImg) return lobbyImg;
}
const rawImg = getAvatarImg(characterId, dir, now, walk);
if (!playTint || !characterId) return rawImg;
return getPlayTintedAvatarSource(rawImg, characterId, dir, now, walk, playTint) || rawImg;
}
function preloadPlayLobbyIdleAvatarsForOccupants() {
const meCid = me.characterId || getPlayCharacterId();
if (meCid) ensurePlayLobbyIdleAvatarImage(meCid);
others.forEach(function (o) {
if (o && o.characterId) ensurePlayLobbyIdleAvatarImage(o.characterId);
});
}
function playHideTopChrome() {
try {
return document.documentElement.classList.contains('play-hide-top-chrome');
} catch (e) {
return true;
}
}
function getStoredCharacterId() {
try {
const v = localStorage.getItem('gameCharacterId');
if (v) return v;
} catch (e) { /* ignore */ }
if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
return firstCharacterDefaultResolved || '';
}
function getPlayCharacterId() {
if (forceDefaultCharacter) {
if (firstCharacterDefaultResolved === null) return LEGACY_PLACEHOLDER_CHARACTER_ID;
return firstCharacterDefaultResolved || '';
}
return getStoredCharacterId();
}
/** บอทพรีวิว: เลือกรหัสคนละตัวจาก roster (ไม่ซ้ำผู้เล่นถ้ามีตัวเลือก) */
function pickPreviewBotCharacterId(botSlotIndex) {
const idx = Math.max(0, Math.floor(Number(botSlotIndex)) || 0);
const roster = playCharacterIdRoster;
const humanId = String(getPlayCharacterId() || '');
if (!roster.length) return humanId || LEGACY_PLACEHOLDER_CHARACTER_ID;
const alts = roster.filter((id) => id !== humanId);
const pool = alts.length ? alts : roster.slice();
return pool[idx % pool.length] || humanId || roster[0];
}
const MOVE_SPEED = 0.15;
/** quiz_carry — ค่าเริ่มเมื่อไม่มี override จาก Admin (รหัสฉากตรงกันใน quiz-settings.json) */
const QUIZ_CARRY_WALK_SPEED_MULT = 1.42;
/** ค่าที่ใช้จริงต่อเฟรม — อัปเดตจาก /api/quiz-settings + join snap */
let quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
const PATH_ARRIVE_THRESH = 0.15;
function clampQuizCarryWalkSpeedMultClient(n, def) {
const v = Number(n);
if (!Number.isFinite(v)) return def;
return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
}
function resolveQuizCarryWalkSpeedMultFromSettingsObj(s) {
const base = QUIZ_CARRY_WALK_SPEED_MULT;
if (!s || typeof s !== 'object') return base;
const forId = String(s.carryWalkSpeedMultForMapId ?? '').trim();
const mult = Number(s.carryWalkSpeedMult);
const mapId = (currentPlayMapId() || '').trim();
if (forId && mapId && forId === mapId && Number.isFinite(mult)) {
return clampQuizCarryWalkSpeedMultClient(mult, base);
}
return base;
}
function applyQuizCarryWalkSpeedFromSettingsObj(s) {
if (!isQuizCarry()) {
quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
return;
}
quizCarryWalkSpeedMultActive = resolveQuizCarryWalkSpeedMultFromSettingsObj(s);
}
function moveSpeedTilesThisFrameForWalk() {
return MOVE_SPEED * (isQuizCarry() ? quizCarryWalkSpeedMultActive : 1);
}
function isMovementKey(code) {
return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1;
}
function isChatFocused() {
return false;
}
let zoom = 1.4;
let froggerScore = 0;
let lastFroggerKey = 0;
let gauntletObstacles = [];
/** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */
let gauntletObsRenderPrev = [];
let gauntletObsRenderNext = [];
let gauntletObsBlendT0 = 0;
let meGauntletJumpTicks = 0;
/** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */
let meGauntletJumpVis = 0;
/** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */
let gauntletRuntimeTickMs = 220;
let gauntletRuntimeJumpTicks = 16;
/** Stack: รอบสวิงต่อวินาที — จาก GET /api/game-timing · ค่าน้อย = สวิงช้า */
let playStackSwingHz = 0.55;
/** Stack: ความกว้างบล็อก (tile) — null = คำนวณจากโซนลงบนแผนที่ */
let playStackBlockWidthTiles = null;
/** ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — จาก game-timing.json */
let playStackTowerMissionTimeSec = 90;
/** ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนเกมจบ — จาก game-timing.json */
let playStackTeamMissesMax = 3;
/** ภารกิจ Tower: ชั้นสำเร็จกี่ชั้น (ฐาน) = Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
let playStackTowerProgressBlocks = 50;
/** รูปบล็อก Stack ต่อที่นั่ง 1–6 (game-timing) — ว่าง = วาดสีเดิม */
let playStackBlockNormalUrls = ['', '', '', '', '', ''];
let playStackBlockHeavyUrls = ['', '', '', '', '', ''];
/** 0–100: โอกาสใช้สไปรต์ “ใหญ่” ต่อการปล่อย (ถ้ามี URL ใหญ่ช่องนั้น) */
let playStackHeavyBlockPercent = 35;
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัว (ch×tile) — จาก GET /api/game-timing */
let playJumpSurviveJumpHeightMult = 1.5;
/** จำกัดเวลารอบภารกิจกระโดดขึ้นแท่น (วินาที) — จาก GET /api/game-timing; 0 = ใช้ค่าเริ่ม 60 ในเกม */
let playJumpSurviveMissionTimeSec = 0;
/** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 จาก game-timing — url ว่าง = วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
let playJumpSurvivePlatformTiles = [
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
];
/** ยิงยานอวกาศ (space_shooter): เวลารอบจาก game-timing; 0 = ใช้ค่าเริ่ม 90 ในเกมถ้าแมปไม่ทับ */
let playSpaceShooterMissionTimeSec = 0;
/** รูปยานช่อง 1–6 จาก game-timing (spawn slot); ว่าง = วาดยานเวกเตอร์ */
let playSpaceShooterShipImageUrls = ['', '', '', '', '', ''];
/** อุกาบาต: [0]=ตก, [1..]=แตก — จาก game-timing */
let playSpaceShooterAsteroidSpriteUrls = [];
let playSpaceShooterAsteroidExplodeFrameMs = 70;
/** ระยะห่างเกิดอุกาบาต (ms) จาก game-timing — แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
let playSpaceShooterAsteroidIntervalMs = 1040;
/** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */
let playSpaceShooterShipDamageOverlayUrls = ['', '', ''];
/** balloon_boss / Mega Virus — จาก game-timing */
let playBalloonBossMissionTimeSec = 0;
let playBalloonBossBossImageUrl = '';
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
let playBalloonBossPlayerBalloonFallbackUrl = '';
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
let gauntletRuntimeTimeLimitSec = 0;
/** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
let gauntletEndsAtMs = null;
/** Crown (mno9kb07): เซิร์ฟยังไม่ปล่อยรัน — หยุด tick / เวลา */
let gauntletCrownRunHeldRemote = false;
/** null | 'howto' | 'countdown' | 'live' — พรีรันก่อน GO */
let gauntletCrownPregamePhase = null;
let gauntletCrownLobbyReadyMap = {};
let gauntletCrownCountdownTimer = null;
let lastGauntletJumpKey = 0;
/** รูป lane: หลาย URL สุ่มตาม id คงที่ · laser: แยกบน/ล่าง/เส้น + สี/ความหนา */
let gauntletLaneImageUrls = [];
let gauntletLaserTopUrl = '';
let gauntletLaserBottomUrl = '';
let gauntletLaserLineUrl = '';
let gauntletLaserFillColor = 'rgba(140,230,255,0.42)';
let gauntletLaserStrokeColor = 'rgba(255,220,255,0.9)';
let gauntletLaserLineWidthPx = 2;
const gauntletAssetImageCache = new Map();
function encodeSpacesInUrlPath(t) {
const s = String(t || '');
const q = s.indexOf('?');
const pathPart = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
return pathPart.replace(/ /g, '%20') + query;
}
function decodeAssetUrlPercentRuns(t) {
let s = String(t || '');
const q = s.indexOf('?');
let base = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
for (let i = 0; i < 2; i += 1) {
try {
const d = decodeURIComponent(base);
if (d === base) break;
base = d;
} catch (e) {
break;
}
}
return base + query;
}
function normalizeGameAssetUrlForWebPlay(t) {
let s = String(t || '');
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
return s;
}
/** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
function normalizeGauntletAssetUrlForPlay(u) {
if (typeof u !== 'string') return '';
let raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim()));
const bare0 = raw.split('?')[0].replace(/\/+$/, '');
if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(bare0) || /^Game\/img\/MegaVirus\/Artboard$/i.test(bare0)) {
raw = '/Game/img/MegaVirus/Artboard%209.png';
}
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
const z = raw.replace(/\/+$/, '');
return z ? encodeSpacesInUrlPath(z) : '';
}
const qIdx = raw.indexOf('?');
const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, '');
const qs = qIdx >= 0 ? raw.slice(qIdx) : '';
if (!base) return '';
if (/\.{4,}/.test(base)) return '';
if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs);
if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs);
return encodeSpacesInUrlPath(base + qs);
}
function ensureGauntletAssetImage(url) {
const u = normalizeGauntletAssetUrlForPlay(typeof url === 'string' ? url : '');
if (!u) return null;
let rec = gauntletAssetImageCache.get(u);
if (rec) return rec;
rec = { img: new Image(), ready: false };
if (/^https?:\/\//i.test(u)) {
try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ }
}
rec.img.onload = function () { rec.ready = true; };
rec.img.onerror = function () { rec.ready = false; };
rec.img.src = u;
/** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */
if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true;
else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false;
gauntletAssetImageCache.set(u, rec);
return rec;
}
/** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */
function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) {
const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim());
const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim());
if (per) {
const rPer = ensureGauntletAssetImage(per);
const img = rPer && rPer.img;
if (img && img.complete && img.naturalWidth > 0) return rPer;
if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb);
return rPer;
}
if (fb) return ensureGauntletAssetImage(fb);
return null;
}
function pickGauntletLaneImageRec(obsId) {
if (!gauntletLaneImageUrls.length) return null;
const n = gauntletLaneImageUrls.length;
const idx = Math.abs(Number(obsId) | 0) % n;
const u = gauntletLaneImageUrls[idx];
return u ? ensureGauntletAssetImage(u) : null;
}
function clampClientLaserColor(s, def) {
const t = String(s || '').trim().slice(0, 100);
if (!t || /[<>"'`]/.test(t)) return def;
return t;
}
let playQuizPhaseLocal = null;
let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
let playQuizText = '';
let playQuizPhaseEndsAt = 0;
let playQuizTimerInterval = null;
/** เกมถูก/ผิด + ภารกิจ mng8a80o: เลขข้อจากเซิร์ฟเวอร์ (1-based) / จำนวนข้อในรอบ — ใช้บรรทัดเล็กเหนือคำถามบนแผนที่ */
let playQuizQuestionIndex = 0;
let playQuizQuestionTotal = 0;
/** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */
let previewQuizPool = [];
/** ดัชนีข้อในรอบพรีวิว (0..len-1) — หลังสับแล้วตัดตาม quizRoundQuestionCount เหมือนเซิร์ฟเวอร์ */
let previewQuizQIndex = 0;
let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 };
let previewQuizStep = 'read';
let previewQuizCurrent = null;
let playLiveQuizScores = {};
/** เกมถูก/ผิด (multiplayer): ใครเคยตอบผิดในเซสชันนี้ — ใช้โชว์แสตมป์ในสรุปภารกิจ mng8a80o */
let playQuizEverWrong = {};
/** เกมถูก/ผิด — ตรงกับ server QUIZ_TF_POINTS_PER_CORRECT */
const QUIZ_TF_POINTS_PER_CORRECT = 10;
const QUIZ_TF_SCORE_PLUS_URL = BASE + '/img/QUESTION/score+.png';
const QUIZ_TF_SCORE_POPUP_MS = 1700;
/** ขนาดป๊อปอัป score+ เทียบกับ tile — เดิม ~1.35×tile; +100% = 2× → 2.7×tile */
const QUIZ_TF_SCORE_POPUP_TILE_MULT = 2.7;
const QUIZ_TF_SCORE_POPUP_MIN_BASE_PX = 112;
let quizTfScorePopups = [];
let quizTfScorePlusImg = null;
/** คะแนนต่อคำตอบที่ถูกเมื่อส่งป้ายที่ฮับ/โซนส่ง — quiz_carry เท่านั้น */
const QUIZ_CARRY_POINTS_PER_CORRECT = 10;
/** ครบเซสชัน (แมปสรุปภารกิจ): โชว์ result-complete / gameover กี่ ms ก่อนแผงสรุปผล */
const QUIZ_CARRY_SESSION_END_SPLASH_MS = 3000;
/** quiz_carry: หยิบตัวเลือกมาวางโซนกลาง — เล่นพร้อมกัน (ไม่สลับตา) */
let quizCarryPool = [];
let quizCarryCurrent = null;
/** editor embed + preview: นับ 3–2–1 ก่อนโชว์คำถามและให้เดิน/หยิบได้ */
let quizCarryEmbedPendingQuestion = null;
let quizCarryEmbedCountdownStartAt = 0;
let quizCarryEmbedCountdownEndAt = 0;
/** editor embed: นับ 3–2–1 รอบสองหลังโชว์คำถาม ก่อนเปิดป้ายตัวเลือกบนแมป */
let quizCarryEmbedPreOptionCountdownStartAt = 0;
let quizCarryEmbedPreOptionCountdownEndAt = 0;
/** quiz_carry: epoch ให้หยิบตัวเลือกได้ · epoch ปิดรอบตอบ (ตั้งที่ Admin แท็บหยิบมาวาง) */
let quizCarryOptionRevealAt = 0;
let quizCarryAnswerCloseAt = 0;
/** หมดเวลาตอบรอบนี้แล้ว — โชว์ TIME'S UP ~3s แล้วเรียก quizCarryAfterRoundResolved */
let quizCarryAnswerTimeupAwaitNext = false;
let quizCarryAnswerRoundTimeupAutoT = null;
let quizCarryAnswerRoundTimeupAdvanceAt = 0;
let quizCarryCarryTimingMs = { carryReadMs: 3000, carryAnswerMs: 5000 };
/** พรีวิว (รวม editor embed): จำนวนข้อที่เล่นก่อนโอเวอร์เลย์จบ — 0 = ไม่จบอัตโนมัติ */
let quizCarrySessionLength = 0;
let quizCarryRoundsCompleted = 0;
let quizCarrySessionEnded = false;
/** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */
let quizCarryResultEndTimer = null;
/** หลังโชว์ result-end (embed preview): รอ 5s แล้วกลับ room-lobby — ใช้ร่วม quiz_carry + crown */
let embedPreviewLobbyReturnTimer = null;
/** ครบเซสชัน (mission summary แมป): รอ splash แล้วเปิด #quiz-carry-mission-overlay */
let quizCarrySessionCompleteResultToSummaryT = null;
/** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */
let quizCarryPregameActive = false;
let playHostId = null;
/** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */
let quizCarryLobbyReadyMap = {};
/** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */
let quizCarryMapPanelTheme = null;
/** จาก /api/quiz-settings quizMapPanelTheme — แผงคำถามบนแผนที่ เกมถูก/ผิด (ไม่ใช่ quiz_carry) */
let quizMapPanelTheme = null;
/** จาก /api/quiz-settings carryEmbedCountdownTheme — สี/ขนาด overlay นับ 3-2-1 (embed) */
let quizCarryEmbedCountdownTheme = null;
/** จาก /api/quiz-settings carryChoicePlaqueThemes — ป้าย canvas ต่อช่องตัวเลือก 0..15 (null = ใช้ค่าเริ่มต้นทุกช่อง) */
let quizCarryChoicePlaqueThemes = null;
/** จาก carryChoicePlaqueMapScale — ขยายป้ายบนแมป + ฟอนต์ (0.85–2.5) */
let quizCarryPlaqueMapScale = 1.25;
const quizCarryChoiceImageCache = new Map();
/** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */
let quizCarryJoinSettingsSnap = null;
/** quiz_carry — โซนตัวเลือกบนแมป / ธีมป้ายต่อช่องสูงสุด 16 */
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
/** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนลง (scroll เพิ่ม world Y ของแพลตฟอร์ม) ไม่ลากทั้งฉาก */
let jumpSurviveCamCenterX = 0;
let jumpSurviveCamCenterY = 0;
let jumpSurviveEliminated = false;
/** จับเวลาโหมดกระโดดให้รอด (วินาทีบน HUD) */
let jumpSurviveSessionStartMs = 0;
let jumpSurvivePlatformScrollPx = 0;
let jumpSurviveVy = 0;
let jumpSurviveLastTickMs = 0;
let jumpSurviveJumpQueued = false;
let jumpSurviveOnGround = false;
let jumpSurviveLastEmitT = 0;
/** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */
let jumpSurviveMissionPhase = null;
let jumpSurviveGameEnded = false;
let jumperMissionCountdownTimer = null;
/** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */
let spaceShooterBullets = [];
let spaceShooterAsteroids = [];
/** { x, y, r, fi, acc } — fi = index เฟรมแตก (0 = urls[1]) */
let spaceShooterAsteroidExplosions = [];
let spaceShooterPopups = [];
let spaceShooterLastTickMs = 0;
let spaceShooterSpawnAccMs = 0;
let spaceShooterFireCd = 0;
let spaceShooterSessionStartMs = 0;
let spaceShooterLastMoveEmit = 0;
let spaceShooterGameEnded = false;
let spaceShooterMissionPhase = null;
let spaceShooterMissionCountdownTimer = null;
/** mng8a80o: howto → countdown → live → ended (สรุปแบบ crown mission) */
let quizQuestionMissionPhase = null;
let quizQuestionMissionCountdownTimer = null;
let quizQuestionMissionDeferredPhase = null;
/** mng8a80o live: virtual joystick (+x right, +y down) normalized ~[-1,1] */
let quizQuestionMissionJoyVecX = 0;
let quizQuestionMissionJoyVecY = 0;
let quizQuestionMissionJoyPointerId = null;
/** Stack Tower (mnn93hpi) — flow เดียวกับ crown / quiz mission */
let stackTowerMissionPhase = null;
let stackTowerMissionCountdownTimer = null;
let stackTowerMissionDeferredPhase = null;
let stackTowerSessionStartAt = 0;
let stackTowerMissionEndedOnce = false;
/** progress ข้ามเกณฑ์ Tower: เริ่มแอนิเมตซูม+เลื่อนแมป — null ยังไม่ข้าม, ตัวเลข = performance.now() ตอนเริ่ม */
let stackTowerPost50AnimStartMs = null;
/** แฟลชรูปผล Tower (timeup / gameover / complete) ก่อน GCM 5 วิ */
let stackTowerResultFlashTimer = null;
const STACK_TOWER_RESULT_FLASH_MS = 5000;
/** รูปอุกาบาตขณะตก: 0 = เต็ม HP (Meteo-1); 1 = โดนยิงแล้วแต่ยังมี HP (Meteo-2 เมื่อมี URL ≥3) */
function spaceShooterAsteroidLiveSpriteIndexPlay(a) {
const urls = playSpaceShooterAsteroidSpriteUrls;
if (!urls || !urls.length) return 0;
const maxHp = Math.max(1, Math.floor(Number(a && a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
const hp = Math.max(0, Math.floor(Number(a && a.hp)) || maxHp);
if (hp >= maxHp) return 0;
if (urls.length >= 3) return 1;
return 0;
}
/** เริ่มแอนิเมชันแตกที่เฟรมถัดจาก «โดนแล้ว» เพื่อไม่ซ้ำ Meteo-2 — แสดง Meteo-3 แล้วจบตาม maxFi */
function spaceShooterAsteroidExplosionStartFiPlay() {
const urls = playSpaceShooterAsteroidSpriteUrls;
if (!urls || urls.length < 2) return 0;
return urls.length >= 3 ? 1 : 0;
}
function spaceShooterSpawnAsteroidExplosion(worldX, worldY, r) {
const urls = playSpaceShooterAsteroidSpriteUrls;
if (!urls || urls.length < 2) return;
spaceShooterAsteroidExplosions.push({
x: worldX,
y: worldY,
r: Math.max(6, Number(r) || 14),
fi: spaceShooterAsteroidExplosionStartFiPlay(),
acc: 0,
});
}
function spaceShooterTickAsteroidExplosions(dt) {
const urls = playSpaceShooterAsteroidSpriteUrls;
if (!urls || urls.length < 2) {
spaceShooterAsteroidExplosions.length = 0;
return;
}
const fms = Math.max(30, Math.min(500, Number(playSpaceShooterAsteroidExplodeFrameMs) || 70));
const maxFi = urls.length - 2;
for (let i = spaceShooterAsteroidExplosions.length - 1; i >= 0; i--) {
const ex = spaceShooterAsteroidExplosions[i];
ex.acc += dt * 1000;
while (ex.acc >= fms) {
ex.acc -= fms;
ex.fi++;
if (ex.fi > maxFi) {
spaceShooterAsteroidExplosions.splice(i, 1);
break;
}
}
}
}
/** balloon_boss — ลูกโป้งยิงบอส (Mega Virus) */
let balloonBossPendingShots = [];
let balloonBossPlayerBullets = [];
let balloonBossBossBullets = [];
let balloonBossSessionStartMs = 0;
let balloonBossLastTickMs = 0;
let balloonBossLastMoveEmit = 0;
let balloonBossPlayerFireCd = 0;
let balloonBossGameEnded = false;
let balloonBossHitFx = [];
/** Mega Virus — ป๊อปดาเมจต่อบอส (ข้อความ +2) เมื่อกระสุนโดนบอส */
let balloonBossScorePopups = [];
let balloonBossBossFireAcc = 0;
/** quiz_battle — โดม MCQ กด E (ข้อจาก battleQuizMcq) */
let quizBattleMcqPool = [];
let quizBattleAnsweredComps = new Set();
let quizBattleModalCompId = null;
const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
document.getElementById('room-id').textContent = spaceId;
function hidePlayQuizHud() {
const sb = document.getElementById('play-quiz-scoreboard');
if (sb) sb.classList.add('is-hidden');
const fb = document.getElementById('play-quiz-feedback');
if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; }
}
function renderPlayQuizScoreboard(scores) {
const wrap = document.getElementById('play-quiz-scoreboard');
const ul = document.getElementById('play-quiz-scoreboard-list');
if (!wrap || !ul || !mapData || (!isQuiz() && !isQuizCarry() && !isQuizBattle())) return;
if (!scores || typeof scores !== 'object') return;
wrap.classList.remove('is-hidden');
ul.textContent = '';
const merged = { ...scores };
others.forEach((_, id) => { if (merged[id] == null) merged[id] = 0; });
if (myId != null && merged[myId] == null) merged[myId] = 0;
const rows = [];
if (myId != null) {
rows.push({ id: myId, nick: me.nickname || 'คุณ', sc: merged[myId] != null ? merged[myId] : 0 });
}
others.forEach((o, id) => {
rows.push({ id, nick: (o && o.nickname) ? String(o.nickname) : id, sc: merged[id] != null ? merged[id] : 0 });
});
rows.sort((a, b) => b.sc - a.sc || a.nick.localeCompare(b.nick, 'th'));
rows.forEach((row) => {
const li = document.createElement('li');
if (row.id === myId) li.className = 'play-quiz-scoreboard-me';
const spN = document.createElement('span');
spN.className = 'play-quiz-scoreboard-name';
spN.textContent = row.nick;
const spV = document.createElement('span');
spV.className = 'play-quiz-scoreboard-val';
spV.textContent = String(row.sc);
li.appendChild(spN);
li.appendChild(spV);
ul.appendChild(li);
});
if (isQuizCarry() && useCyberPlayHud()) {
wrap.classList.add('is-hidden');
}
/* แมป mng8a80o: คะแนนอยู่ที่ cyber SCORE ซ้ายแล้ว — ซ่อนกล่อง "คะแนน" มุมขวาไม่ให้ซ้ำกับ mock */
if (isQuizQuestionMissionUiMapPlay()) {
wrap.classList.add('is-hidden');
}
}
function initPlayLiveQuizScoresZeros() {
if ((!isQuiz() && !isQuizCarry() && !isQuizBattle()) || myId == null) return;
playLiveQuizScores = {};
playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
if (isQuiz()) playQuizEverWrong = {};
renderPlayQuizScoreboard(playLiveQuizScores);
}
function showPlayQuizFeedback(r) {
const el = document.getElementById('play-quiz-feedback');
if (!el || !r || !r.results || myId == null) return;
let mine = null;
let botRight = 0, botWrong = 0;
for (let i = 0; i < r.results.length; i++) {
const row = r.results[i];
if (row.id === myId) mine = row;
else if (isPreviewBotId(row.id)) {
if (row.right) botRight++;
else botWrong++;
}
}
if (!mine) return;
el.classList.remove('is-hidden');
const botExtra = playBotsEnabled() && (botRight + botWrong > 0)
? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)'
: '';
if (mine.right) {
el.className = 'play-quiz-feedback play-quiz-feedback-ok';
el.textContent = 'คุณตอบถูก · +' + QUIZ_TF_POINTS_PER_CORRECT + ' · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra;
} else {
el.className = 'play-quiz-feedback play-quiz-feedback-bad';
el.textContent = (mine.choice == null
? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด'
: 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra;
}
if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT);
window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200);
}
function ensureQuizTfScorePlusImgPlay() {
if (quizTfScorePlusImg && quizTfScorePlusImg.src) return;
quizTfScorePlusImg = new Image();
quizTfScorePlusImg.src = QUIZ_TF_SCORE_PLUS_URL;
quizTfScorePlusImg.onerror = function () {
this.onerror = null;
};
}
/** โชว์ score+.png กลางโซนจริง/เท็จที่เป็นคำตอบข้อนี้ (world px) */
function spawnQuizTrueFalseScorePopupPlay(correctTrue) {
if (!isQuiz() || !mapData) return;
const grid = correctTrue ? mapData.quizTrueArea : mapData.quizFalseArea;
const b = getTileBoundsForOnesGrid(grid);
if (!b) return;
const ts = tileSize || 32;
const wx = ((b.minX + b.maxX + 1) / 2) * ts;
const wy = ((b.minY + b.maxY + 1) / 2) * ts;
const now = Date.now();
ensureQuizTfScorePlusImgPlay();
quizTfScorePopups.push({ wx, wy, startMs: now, untilMs: now + QUIZ_TF_SCORE_POPUP_MS });
}
function drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs) {
if (!quizTfScorePopups.length) return;
const img = quizTfScorePlusImg;
if (!img || !img.complete || !img.naturalWidth) return;
const now = typeof timeMs === 'number' ? timeMs : Date.now();
quizTfScorePopups = quizTfScorePopups.filter((p) => p.untilMs > now);
const baseW = Math.max(QUIZ_TF_SCORE_POPUP_MIN_BASE_PX, (tileSize || 32) * zDraw * QUIZ_TF_SCORE_POPUP_TILE_MULT);
for (let i = 0; i < quizTfScorePopups.length; i++) {
const p = quizTfScorePopups[i];
const span = Math.max(1, p.untilMs - p.startMs);
const t = Math.max(0, Math.min(1, (now - p.startMs) / span));
const [sx, sy] = worldToScreen(p.wx, p.wy);
const rise = t * 56 * zDraw;
const fade = t < 0.72 ? 1 : Math.max(0, 1 - (t - 0.72) / 0.28);
const pulse = 1 + Math.sin(t * Math.PI) * 0.1;
const scale = (baseW / img.naturalWidth) * pulse;
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
ctx.save();
ctx.globalAlpha = fade;
ctx.drawImage(img, sx - w / 2, sy - h / 2 - rise, w, h);
ctx.restore();
}
}
function getTileBoundsForOnesGrid(grid) {
if (!grid || !grid.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let yy = 0; yy < grid.length; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (Number(row[xx]) === 1) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
}
}
}
if (minX === Infinity) return null;
return { minX, minY, maxX, maxY };
}
function getQuizQuestionAreaTileBounds(md) {
if (!md || md.gameType !== 'quiz') return null;
return getTileBoundsForOnesGrid(md.quizQuestionArea);
}
/** quiz_carry: โซนทองพิเศษสำหรับข้อความคำถาม (ถ้าไม่วาด ใช้โซนกลางม่วงแทนใน syncPlayQuizMapPanel) */
function getQuizCarryQuestionAreaTileBounds(md) {
if (!md || md.gameType !== 'quiz_carry') return null;
return getTileBoundsForOnesGrid(md.quizQuestionArea);
}
function clearQuizMapPanelThemeInline(panel, textEl) {
if (!panel || !textEl) return;
panel.classList.remove('quiz-map-question-panel--has-kicker');
panel.classList.remove('quiz-map-question-panel--question-mission-live');
const qKick = document.getElementById('quiz-map-question-kicker');
if (qKick) {
qKick.textContent = '';
qKick.classList.add('is-hidden');
qKick.setAttribute('aria-hidden', 'true');
}
panel.style.removeProperty('background-image');
panel.style.removeProperty('background-size');
panel.style.removeProperty('background-position');
delete panel.dataset.qmPlaqueTry;
panel.style.removeProperty('--qmap-bg');
panel.style.removeProperty('--qmap-border');
panel.style.removeProperty('--qmap-border-w');
panel.style.removeProperty('--qmap-shadow');
panel.style.removeProperty('background');
panel.style.removeProperty('border');
panel.style.removeProperty('border-color');
panel.style.removeProperty('border-width');
panel.style.removeProperty('border-style');
panel.style.removeProperty('box-shadow');
textEl.style.removeProperty('--qmap-text');
textEl.style.removeProperty('--qmap-text-shadow');
textEl.style.removeProperty('color');
textEl.style.removeProperty('text-shadow');
textEl.style.removeProperty('font-size');
textEl.style.removeProperty('line-height');
textEl.style.removeProperty('width');
textEl.style.removeProperty('box-sizing');
textEl.style.removeProperty('max-height');
textEl.style.removeProperty('overflow');
}
function clearQuizMapQuestionCarryFeedback() {
if (typeof window.__quizMapCarryFeedbackT === 'number') {
clearTimeout(window.__quizMapCarryFeedbackT);
window.__quizMapCarryFeedbackT = null;
}
const icon = document.getElementById('quiz-map-q-feedback-icon');
const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
const scoreEl = document.getElementById('quiz-map-q-feedback-score');
if (icon) {
icon.classList.add('is-hidden');
icon.removeAttribute('src');
}
if (scoreEl) scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
if (scoreWrap) {
scoreWrap.classList.add('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'true');
}
}
/** ไอคอนถูก/ผิดที่ขอบบนแผง + score+10 ตรงกลางใต้ข้อความ (เฉพาะถูก) — ตาม mock quiz_carry */
function showQuizMapQuestionCarryFeedback(isCorrect) {
if (!isQuizCarry()) return;
const panel = document.getElementById('quiz-map-question-panel');
const icon = document.getElementById('quiz-map-q-feedback-icon');
const scoreWrap = document.getElementById('quiz-map-q-feedback-score-wrap');
const scoreEl = document.getElementById('quiz-map-q-feedback-score');
if (!panel || !icon || panel.classList.contains('is-hidden')) return;
clearQuizMapQuestionCarryFeedback();
icon.src = isCorrect ? QUIZ_CARRY_MAP_FEEDBACK_IMG.correct : QUIZ_CARRY_MAP_FEEDBACK_IMG.incorrect;
icon.classList.remove('is-hidden');
if (isCorrect && scoreWrap && scoreEl) {
scoreEl.src = QUIZ_CARRY_MAP_FEEDBACK_IMG.scorePlus;
scoreWrap.classList.remove('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'false');
scoreEl.classList.remove('quiz-map-q-feedback-score--pop');
void scoreEl.offsetWidth;
scoreEl.classList.add('quiz-map-q-feedback-score--pop');
} else if (scoreWrap) {
scoreWrap.classList.add('is-hidden');
scoreWrap.setAttribute('aria-hidden', 'true');
}
window.__quizMapCarryFeedbackT = setTimeout(() => {
window.__quizMapCarryFeedbackT = null;
clearQuizMapQuestionCarryFeedback();
}, QUIZ_MAP_CARRY_FEEDBACK_MS);
}
/** ค่าเริ่มต้นแผงคำถามบนแผนที่ (ตรงกับ server defaultCarryMapPanelTheme) — ใช้เมื่อยังไม่โหลดจาก API */
function defaultCarryMapPanelThemePlay() {
return {
panelBg: 'rgba(12, 14, 28, 0.88)',
panelBorder: 'rgba(255, 214, 102, 0.7)',
borderWidthPx: 2,
textColor: '#f1f5f9',
questionFontMinPx: 10,
questionFontMaxPx: 24,
};
}
/** ตรงกับ drawQuizCarryChoiceLabels — carryChoicePlaqueMapScale */
function quizCarryPlaqueMapScaleClampedPlay() {
const sc = Number(quizCarryPlaqueMapScale);
if (!Number.isFinite(sc)) return 1.25;
return Math.max(0.85, Math.min(2.5, sc));
}
/**
* ขนาดตัวอักษรแผงคำถาม DOM ให้เท่าป้ายคำตอบบนพื้น: Math.max(10, Math.min(24, tileSize*zoom*0.24*ps))
* แล้ว clamp ด้วย questionFontMinPx / questionFontMaxPx จากธีม (ถ้าต้องการใหญ่กว่าป้ายได้โดยยกเพดาน max)
*/
function quizMapQuestionFontPxLikeCarryPlaques(th, zoomVal) {
const ts = tileSize * zoomVal;
const ps = quizCarryPlaqueMapScaleClampedPlay();
const base = Math.max(10, Math.min(24, ts * 0.24 * ps));
const fb = defaultCarryMapPanelThemePlay();
let mn = Number(th && th.questionFontMinPx);
let mx = Number(th && th.questionFontMaxPx);
if (!Number.isFinite(mn)) mn = fb.questionFontMinPx;
if (!Number.isFinite(mx)) mx = fb.questionFontMaxPx;
mn = Math.round(Math.max(8, Math.min(40, mn)));
mx = Math.round(Math.max(10, Math.min(72, mx)));
if (mx < mn) {
const s = mn;
mn = mx;
mx = s;
}
return Math.max(mn, Math.min(mx, base));
}
/**
* ลด px ทีละ 1 จนเนื้อหาไม่ล้นกล่อง (ไม่ใช้ scrollbar)
* zoom out กล่องเตี้ยมาก: อนุญาตต่ำกว่า questionFontMinPx ของธีมได้ถึง hardMin (≥6) ไม่ให้บรรทัดถูกตัด
* เกมถูก/ผิด: แผงมีคลาส quiz-map-question-panel--true-false จัดข้อความกึ่งกลางแนวตั้งในโซน
*/
function fitQuizMapQuestionFontToPanel(panel, textEl, startPx, minPx) {
const cs = window.getComputedStyle(panel);
const padT = parseFloat(cs.paddingTop) || 0;
const padB = parseFloat(cs.paddingBottom) || 0;
const padL = parseFloat(cs.paddingLeft) || 0;
const padR = parseFloat(cs.paddingRight) || 0;
let kickerReserve = 0;
if (panel.classList.contains('quiz-map-question-panel--question-mission-live')
&& panel.classList.contains('quiz-map-question-panel--has-kicker')) {
kickerReserve = 30;
}
const availH = Math.max(12, panel.clientHeight - padT - padB - kickerReserve);
const availW = Math.max(24, panel.clientWidth - padL - padR);
const themeMin = Math.max(8, Math.floor(Number(minPx) || 8));
const hardMin = Math.min(themeMin, Math.max(6, Math.floor(availH / 7)));
let px = Math.round(Math.min(80, Math.max(hardMin, Number(startPx) || hardMin)));
textEl.style.setProperty('line-height', '1.28', 'important');
textEl.style.setProperty('width', '100%', 'important');
textEl.style.setProperty('box-sizing', 'border-box', 'important');
textEl.style.removeProperty('max-height');
textEl.style.setProperty('overflow', 'hidden', 'important');
for (let i = 0; i < 96 && px > hardMin; i++) {
textEl.style.setProperty('font-size', String(px) + 'px', 'important');
const sh = textEl.scrollHeight;
const sw = textEl.scrollWidth;
if (sh <= availH + 2 && sw <= availW + 2) break;
px -= 1;
}
textEl.style.setProperty('font-size', String(px) + 'px', 'important');
textEl.style.setProperty('max-height', availH + 'px', 'important');
}
/** ธีมจาก quiz-settings / join API — ถ้า JSON ไม่มีฟอนต์ให้ใช้ค่าเริ่มต้น */
function parseCarryMapPanelThemeObject(raw) {
let t = raw;
if (typeof t === 'string') {
try {
t = JSON.parse(t);
} catch (e) {
t = null;
}
}
if (!t || typeof t !== 'object') return null;
const bw = Number(t.borderWidthPx);
const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
const fb = defaultCarryMapPanelThemePlay();
let qMin = Number(t.questionFontMinPx);
let qMax = Number(t.questionFontMaxPx);
if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
qMin = Math.round(Math.max(10, Math.min(40, qMin)));
qMax = Math.round(Math.max(14, Math.min(56, qMax)));
if (qMax < qMin) {
const s = qMin;
qMin = qMax;
qMax = s;
}
return {
panelBg: String(t.panelBg || '').trim().slice(0, 120),
panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
borderWidthPx,
textColor: String(t.textColor || '').trim().slice(0, 120),
questionFontMinPx: qMin,
questionFontMaxPx: qMax,
};
}
/**
* ชั้นทับจาก carryMapPanelTheme ในไฟล์แมป — ฟอนต์ใส่เฉพาะเมื่อ JSON มี key จริง
* (กันธีมแมปมีแค่สีแล้วไปทับขนาดฟอนต์จาก Admin เป็นค่าเริ่ม 16/24)
*/
function parseCarryMapPanelThemeMapOverlay(raw) {
let t = raw;
if (typeof t === 'string') {
try {
t = JSON.parse(t);
} catch (e) {
t = null;
}
}
if (!t || typeof t !== 'object') return null;
const bw = Number(t.borderWidthPx);
const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2;
const out = {
panelBg: String(t.panelBg || '').trim().slice(0, 120),
panelBorder: String(t.panelBorder || '').trim().slice(0, 120),
borderWidthPx,
textColor: String(t.textColor || '').trim().slice(0, 120),
};
const hasMin = Object.prototype.hasOwnProperty.call(t, 'questionFontMinPx');
const hasMax = Object.prototype.hasOwnProperty.call(t, 'questionFontMaxPx');
if (hasMin || hasMax) {
const fb = defaultCarryMapPanelThemePlay();
let qMin = hasMin ? Number(t.questionFontMinPx) : fb.questionFontMinPx;
let qMax = hasMax ? Number(t.questionFontMaxPx) : fb.questionFontMaxPx;
if (!Number.isFinite(qMin)) qMin = fb.questionFontMinPx;
if (!Number.isFinite(qMax)) qMax = fb.questionFontMaxPx;
qMin = Math.round(Math.max(10, Math.min(40, qMin)));
qMax = Math.round(Math.max(14, Math.min(56, qMax)));
if (qMax < qMin) {
const s = qMin;
qMin = qMax;
qMax = s;
}
out.questionFontMinPx = qMin;
out.questionFontMaxPx = qMax;
}
return out;
}
function setQuizCarryMapPanelThemeFromApi(s) {
quizCarryMapPanelTheme = null;
if (!s || typeof s !== 'object') return;
const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme);
if (!parsed) return;
quizCarryMapPanelTheme = parsed;
}
function setQuizMapPanelThemeFromApi(s) {
quizMapPanelTheme = null;
if (!s || typeof s !== 'object') return;
const parsed = parseCarryMapPanelThemeObject(s.quizMapPanelTheme);
if (!parsed) return;
quizMapPanelTheme = parsed;
}
function defaultCarryChoicePlaqueThemePlay() {
return {
borderMode: 'neon',
fixedBorder: 'rgba(122, 200, 255, 0.9)',
fillBg: 'rgba(12, 10, 20, 0.88)',
textColor: 'rgba(248, 249, 255, 1)',
borderWidthPx: 2.5,
plaqueImageUrl: '',
};
}
function parseCarryChoicePlaqueThemeObject(raw) {
const d = defaultCarryChoicePlaqueThemePlay();
if (!raw || typeof raw !== 'object') return d;
const safeColor = (x, fb) => {
const t = String(x == null ? '' : x).trim().slice(0, 120);
if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
return fb;
};
const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon';
let bw = Number(raw.borderWidthPx);
if (!Number.isFinite(bw)) bw = d.borderWidthPx;
bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10;
return {
borderMode: mode,
fixedBorder: safeColor(raw.fixedBorder, d.fixedBorder),
fillBg: safeColor(raw.fillBg, d.fillBg),
textColor: safeColor(raw.textColor, d.textColor),
borderWidthPx: bw,
plaqueImageUrl: sanitizeQuizCarryImageUrlClient(raw.plaqueImageUrl),
};
}
function buildCarryChoicePlaqueThemesArrayFromApi(s) {
if (!s || typeof s !== 'object') return null;
if (Array.isArray(s.carryChoicePlaqueThemes) && s.carryChoicePlaqueThemes.length) {
const out = [];
for (let i = 0; i < QUIZ_CARRY_MAX_OPTION_SLOTS; i++) {
out.push(parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueThemes[i]));
}
return out;
}
if (s.carryChoicePlaqueTheme && typeof s.carryChoicePlaqueTheme === 'object') {
const one = parseCarryChoicePlaqueThemeObject(s.carryChoicePlaqueTheme);
return Array.from({ length: QUIZ_CARRY_MAX_OPTION_SLOTS }, () => ({ ...one }));
}
return null;
}
function getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex) {
const i = Math.max(0, Math.min(QUIZ_CARRY_MAX_OPTION_SLOTS - 1, choiceIndex | 0));
if (quizCarryChoicePlaqueThemes && quizCarryChoicePlaqueThemes[i]) {
return quizCarryChoicePlaqueThemes[i];
}
return defaultCarryChoicePlaqueThemePlay();
}
function setQuizCarryChoicePlaqueThemeFromApi(s) {
quizCarryChoicePlaqueThemes = buildCarryChoicePlaqueThemesArrayFromApi(s);
}
function sanitizeQuizCarryImageUrlClient(u) {
const s = String(u == null ? '' : u).trim().slice(0, 512);
if (!s || /[\s<>'"`]/.test(s)) return '';
if (s.startsWith('/')) {
if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return '';
return s;
}
const low = s.toLowerCase();
if (!low.startsWith('https://') && !low.startsWith('http://')) return '';
try {
const parsed = new URL(s);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return s;
} catch (e) {
return '';
}
}
/** crossOrigin=anonymous กับ same-origin บางเซิร์ฟ/nginx ทำให้โหลดรูปล้ม — ใช้เฉพาะข้ามโดเมน */
function quizCarryApplyImageCrossOrigin(img, rawUrl) {
if (!img) return;
try {
img.removeAttribute('crossorigin');
const u = sanitizeQuizCarryImageUrlClient(rawUrl);
if (!u || u.startsWith('data:')) return;
let imgOrigin = '';
if (u.startsWith('http://') || u.startsWith('https://')) {
imgOrigin = new URL(u).origin;
} else if (u.startsWith('/')) {
imgOrigin = window.location.origin;
}
if (imgOrigin && imgOrigin !== window.location.origin) {
img.crossOrigin = 'anonymous';
}
} catch (e) { /* ignore */ }
}
function preloadQuizCarryChoiceImages(q) {
if (!q) return;
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
for (let i = 0; i < q.choiceImageUrls.length; i++) {
const raw = sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[i]);
if (!raw) continue;
if (quizCarryChoiceImageCache.has(raw)) continue;
const im = new Image();
quizCarryApplyImageCrossOrigin(im, raw);
im.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
quizCarryChoiceImageCache.set(raw, im);
try {
im.src = raw;
} catch (e) { /* ignore */ }
}
}
if (Array.isArray(q.choices)) {
for (let j = 0; j < q.choices.length; j++) {
const raw2 = getQuizCarryPlaqueImageUrlForIndex(q, j);
if (!raw2 || quizCarryChoiceImageCache.has(raw2)) continue;
const im2 = new Image();
quizCarryApplyImageCrossOrigin(im2, raw2);
im2.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
quizCarryChoiceImageCache.set(raw2, im2);
try {
im2.src = raw2;
} catch (e) { /* ignore */ }
}
}
}
function getQuizCarryChoiceImageCached(url) {
const u = sanitizeQuizCarryImageUrlClient(url);
if (!u) return null;
const im = quizCarryChoiceImageCache.get(u);
if (im && im.complete && im.naturalWidth > 0) return im;
return null;
}
function getQuizCarryChoiceImageUrlForIndex(q, idx) {
if (!q || !Array.isArray(q.choiceImageUrls)) return '';
const u = q.choiceImageUrls[idx];
return sanitizeQuizCarryImageUrlClient(u);
}
/** รูปป้าย: คำถามต่อช่องก่อน แล้วค่อยรูปจากธีมช่อง (carryChoicePlaqueThemes) */
function getQuizCarryPlaqueImageUrlForIndex(q, idx) {
const perQ = getQuizCarryChoiceImageUrlForIndex(q, idx);
if (perQ) return perQ;
const th = getEffectiveCarryChoicePlaqueThemeForChoice(idx);
const u = th && th.plaqueImageUrl ? sanitizeQuizCarryImageUrlClient(th.plaqueImageUrl) : '';
return u || '';
}
function defaultCarryEmbedCountdownThemePlay() {
return {
overlayBackdrop: 'rgba(6, 8, 14, 0.52)',
innerBg: 'rgba(0, 0, 0, 0.78)',
innerBorder: 'rgba(94, 234, 212, 0.82)',
innerBorderWpx: 1,
innerRadiusPx: 14,
digitColor: '#ffe066',
mapDigitCqmin: 78,
mapDigitCqh: 82,
mapDigitMaxPx: 220,
screenDigitVw: 32,
screenDigitMaxPx: 200,
};
}
function parseCarryEmbedCountdownThemeObject(raw) {
const d = defaultCarryEmbedCountdownThemePlay();
if (!raw || typeof raw !== 'object') return d;
const clampN = (v, lo, hi, def) => {
const n = Number(v);
if (!Number.isFinite(n)) return def;
return Math.max(lo, Math.min(hi, Math.round(n)));
};
const clipStr = (x, maxLen) => {
const t = String(x == null ? '' : x).trim().slice(0, maxLen);
return t || null;
};
const safeColor = (x, fb) => {
const t = clipStr(x, 120);
if (!t || /url\s*\(|expression|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fb;
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t;
if (/^rgba?\(\s*[^)]+\)$/i.test(t)) return t.replace(/\s+/g, ' ').trim();
return fb;
};
return {
overlayBackdrop: safeColor(raw.overlayBackdrop, d.overlayBackdrop),
innerBg: safeColor(raw.innerBg, d.innerBg),
innerBorder: safeColor(raw.innerBorder, d.innerBorder),
innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx),
innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx),
digitColor: safeColor(raw.digitColor, d.digitColor),
mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin),
mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh),
mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx),
screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw),
screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx),
};
}
function setQuizCarryEmbedCountdownThemeFromApi(s) {
quizCarryEmbedCountdownTheme = parseCarryEmbedCountdownThemeObject(s && s.carryEmbedCountdownTheme);
applyQuizCarryEmbedCountdownThemeToDom();
}
const CARRY_ECD_VAR_KEYS = [
'--carry-ecd-overlay',
'--carry-ecd-inner-bg',
'--carry-ecd-inner-border',
'--carry-ecd-inner-border-w',
'--carry-ecd-inner-radius',
'--carry-ecd-digit-color',
'--carry-ecd-map-digit-fs',
'--carry-ecd-screen-vw',
'--carry-ecd-screen-max',
];
function clearQuizCarryEmbedCountdownThemeDomVars() {
const root = document.getElementById('quiz-carry-embed-countdown');
if (!root) return;
CARRY_ECD_VAR_KEYS.forEach((k) => { try { root.style.removeProperty(k); } catch (e) { /* ignore */ } });
}
/** ใส่ CSS variables บน #quiz-carry-embed-countdown — อ่านจาก quizCarryEmbedCountdownTheme */
function applyQuizCarryEmbedCountdownThemeToDom() {
const root = document.getElementById('quiz-carry-embed-countdown');
const numEl = document.getElementById('quiz-carry-embed-countdown-num');
if (!root) return;
clearQuizCarryEmbedCountdownThemeDomVars();
const th = quizCarryEmbedCountdownTheme || defaultCarryEmbedCountdownThemePlay();
root.style.setProperty('--carry-ecd-overlay', th.overlayBackdrop);
root.style.setProperty('--carry-ecd-inner-bg', th.innerBg);
root.style.setProperty('--carry-ecd-inner-border', th.innerBorder);
root.style.setProperty('--carry-ecd-inner-border-w', `${Math.max(0, Math.min(12, Math.round(Number(th.innerBorderWpx) || 1)))}px`);
root.style.setProperty('--carry-ecd-inner-radius', `${Math.max(0, Math.min(32, Math.round(Number(th.innerRadiusPx) || 12)))}px`);
root.style.setProperty('--carry-ecd-digit-color', th.digitColor);
const cqmin = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqmin) || 78)));
const cqh = Math.max(35, Math.min(100, Math.round(Number(th.mapDigitCqh) || 82)));
const mpx = Math.max(48, Math.min(400, Math.round(Number(th.mapDigitMaxPx) || 200)));
root.style.setProperty('--carry-ecd-map-digit-fs', `clamp(14px, min(${cqmin}cqmin, ${cqh}cqh), ${mpx}px)`);
const vw = Math.max(6, Math.min(44, Math.round(Number(th.screenDigitVw) || 28)));
const smx = Math.max(48, Math.min(220, Math.round(Number(th.screenDigitMaxPx) || 132)));
root.style.setProperty('--carry-ecd-screen-vw', `${vw}vw`);
root.style.setProperty('--carry-ecd-screen-max', `${smx}px`);
if (numEl && String(numEl.tagName || '').toUpperCase() !== 'IMG') {
numEl.style.textShadow = '0 0 0.45em currentColor, 0 4px 14px rgba(0,0,0,0.55)';
} else if (numEl) {
try { numEl.style.removeProperty('text-shadow'); } catch (e) { /* ignore */ }
}
}
function setCountdown321QuestionAssetGraphic(numEl, n) {
if (!numEl) return;
const d = Math.max(1, Math.min(3, n));
if (String(numEl.tagName || '').toUpperCase() === 'IMG') {
numEl.src = COUNTDOWN_321_IMG_BASE + d + '.png';
numEl.alt = String(d);
} else {
numEl.textContent = String(d);
}
}
/** default ← quiz-settings/API ← แมป (สีจากแมปทับได้; ฟอนต์จากแมปเฉพาะเมื่อแมประบุ key) */
function getEffectiveCarryMapPanelThemeForApply() {
const d = defaultCarryMapPanelThemePlay();
const api = quizCarryMapPanelTheme != null && typeof quizCarryMapPanelTheme === 'object' ? quizCarryMapPanelTheme : null;
let merged = api ? { ...d, ...api } : { ...d };
if (mapData && mapData.carryMapPanelTheme != null) {
const mp = parseCarryMapPanelThemeMapOverlay(mapData.carryMapPanelTheme);
if (mp) merged = { ...merged, ...mp };
}
return merged;
}
/** เกมถูก/ผิด: default ← quizMapPanelTheme (API) ← quizMapPanelTheme บนแมป (ถ้ามี) */
function getEffectiveQuizMapPanelThemeForApply() {
const d = defaultCarryMapPanelThemePlay();
const api = quizMapPanelTheme != null && typeof quizMapPanelTheme === 'object' ? quizMapPanelTheme : null;
let merged = api ? { ...d, ...api } : { ...d };
if (mapData && mapData.quizMapPanelTheme != null) {
const mp = parseCarryMapPanelThemeMapOverlay(mapData.quizMapPanelTheme);
if (mp) merged = { ...merged, ...mp };
}
return merged;
}
function paintQuizMapPanelTheme(panel, textEl, th) {
if (!panel || !textEl || !th) return;
clearQuizMapPanelThemeInline(panel, textEl);
const bgStr = th.panelBg != null ? String(th.panelBg).trim() : '';
const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : '';
const txStr = th.textColor != null ? String(th.textColor).trim() : '';
const wRaw = Number(th.borderWidthPx);
const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0;
const bgUse = bgStr || 'transparent';
const brUse = brStr || 'transparent';
const txUse = txStr || '#f1f5f9';
panel.style.setProperty('--qmap-bg', bgUse);
panel.style.setProperty('--qmap-border', brUse);
panel.style.setProperty('--qmap-border-w', String(w) + 'px');
panel.style.setProperty('--qmap-shadow', 'none');
textEl.style.setProperty('--qmap-text', txUse);
textEl.style.setProperty('--qmap-text-shadow', 'none');
/* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */
panel.style.setProperty('background', bgUse, 'important');
panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important');
panel.style.setProperty('box-shadow', 'none', 'important');
textEl.style.setProperty('color', txUse, 'important');
textEl.style.setProperty('text-shadow', 'none', 'important');
}
function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) {
if (!panel || !textEl) return;
if (!isQuizCarry()) {
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
paintQuizMapPanelTheme(panel, textEl, getEffectiveCarryMapPanelThemeForApply());
}
function applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl) {
if (!panel || !textEl) return;
if (!isQuiz() || isQuizCarry()) return;
paintQuizMapPanelTheme(panel, textEl, getEffectiveQuizMapPanelThemeForApply());
}
function syncPlayQuizMapPanel() {
const panel = document.getElementById('quiz-map-question-panel');
const textEl = document.getElementById('quiz-map-question-text');
if (!panel || !textEl || !mapData) return;
/* เอดิเตอร์ embed: ซ่อนแผงทองเฉพาะ quiz ทั่วไป — แมปภารกิจ mng8a80o ต้องโชว์คำถามในโซนที่วาด (quizQuestionArea) */
if (previewMode && editorEmbedReturn && !isQuizCarry() && !(isQuiz() && isQuizQuestionMissionUiMapPlay())) {
clearQuizMapQuestionCarryFeedback();
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
if (isQuizBattle()) {
clearQuizMapQuestionCarryFeedback();
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
if (!isQuiz() && !isQuizCarry()) {
clearQuizMapQuestionCarryFeedback();
return;
}
const bounds = isQuiz()
? getQuizQuestionAreaTileBounds(mapData)
: (getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData));
const text = (playQuizText || '').trim();
if (!bounds || !text) {
clearQuizMapQuestionCarryFeedback();
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
clearQuizMapPanelThemeInline(panel, textEl);
return;
}
const qmCenter = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const carryCam = !qmCenter && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
const camX = qmCenter ? qmCenter.cx : (carryCam ? carryCam.cx : me.x * tileSize);
const camY = qmCenter ? qmCenter.cy : (carryCam ? carryCam.cy : me.y * tileSize);
const zDom = playDomSyncZoom();
const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
textEl.textContent = text;
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.width = Math.round(Math.max(48, wPx)) + 'px';
panel.style.height = Math.round(Math.max(40, hPx)) + 'px';
panel.classList.remove('is-hidden');
panel.setAttribute('aria-hidden', 'false');
if (isQuiz() && !isQuizCarry()) {
panel.classList.add('quiz-map-question-panel--true-false');
} else {
panel.classList.remove('quiz-map-question-panel--true-false');
}
if (isQuizCarry()) {
applyQuizCarryMapPanelThemeIfNeeded(panel, textEl);
} else if (isQuiz()) {
applyQuizTrueFalseMapPanelThemeIfNeeded(panel, textEl);
} else {
clearQuizMapPanelThemeInline(panel, textEl);
}
const thFont = isQuizCarry()
? getEffectiveCarryMapPanelThemeForApply()
: getEffectiveQuizMapPanelThemeForApply();
const startFontPx = quizMapQuestionFontPxLikeCarryPlaques(thFont, zDom);
const mnFont = Math.max(8, Math.round(Number(thFont.questionFontMinPx) || 10));
/* ไม่โหลด hud-question-plaque.png อัตโนมัติ — บนเซิร์ฟมักไม่มีไฟล์ → 404 ใน Network; ใช้คลาส + CSS แทน (อัปโหลด PNG แล้วค่อยผูกทีหลังได้) */
if (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live') {
panel.classList.add('quiz-map-question-panel--question-mission-live');
} else {
panel.classList.remove('quiz-map-question-panel--question-mission-live');
if (panel.dataset.qmPlaqueTry) {
panel.style.removeProperty('background-image');
panel.style.removeProperty('background-size');
panel.style.removeProperty('background-position');
delete panel.dataset.qmPlaqueTry;
}
}
const kickerEl = document.getElementById('quiz-map-question-kicker');
const missionLiveQ = isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
const inReadOrAnswer = playQuizPhaseLocal === 'read' || playQuizPhaseLocal === 'answer';
if (kickerEl) {
let showK = false;
let line = '';
if (missionLiveQ && inReadOrAnswer) {
const qi = Math.max(0, Number(playQuizQuestionIndex) || 0);
const qt = Math.max(0, Number(playQuizQuestionTotal) || 0);
if (qi > 0 && qt > 0) {
line = '- Quiz ' + qi + ' / ' + qt + ' -';
showK = true;
} else if (qi > 0) {
line = '- Quiz ' + qi + ' -';
showK = true;
}
}
if (showK && line) {
kickerEl.textContent = line;
kickerEl.classList.remove('is-hidden');
kickerEl.setAttribute('aria-hidden', 'false');
panel.classList.add('quiz-map-question-panel--has-kicker');
} else {
kickerEl.textContent = '';
kickerEl.classList.add('is-hidden');
kickerEl.setAttribute('aria-hidden', 'true');
panel.classList.remove('quiz-map-question-panel--has-kicker');
}
}
fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont);
}
/** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */
function syncQuizCarryTimeupDeskLayerPosition() {
const layer = document.getElementById('quiz-carry-timeup-desk-layer');
if (!layer || layer.classList.contains('is-hidden')) return;
const anchor = layer.querySelector('.qc-timeup-desk-anchor')
|| layer.querySelector('.qc-timeup-desk-inner')
|| (() => {
const img = document.getElementById('quiz-carry-timeup-txt-img');
return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null;
})();
if (!anchor || !canvas) return;
const clearToFullStack = () => {
anchor.style.left = '0';
anchor.style.top = '0';
anchor.style.width = '100%';
anchor.style.height = '100%';
};
const stack = document.getElementById('play-canvas-stack');
const panel = document.getElementById('quiz-map-question-panel');
if (stack && panel && !panel.classList.contains('is-hidden')) {
try {
const sr = stack.getBoundingClientRect();
const pr = panel.getBoundingClientRect();
const rw = pr.width;
const rh = pr.height;
if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) {
anchor.style.left = Math.round(pr.left - sr.left) + 'px';
anchor.style.top = Math.round(pr.top - sr.top) + 'px';
anchor.style.width = Math.round(rw) + 'px';
anchor.style.height = Math.round(rh) + 'px';
return;
}
} catch (e) { /* fallback ด้านล่าง */ }
}
const pl = panel && panel.style && String(panel.style.left || '').trim();
const pt = panel && panel.style && String(panel.style.top || '').trim();
const pw = panel && panel.style && String(panel.style.width || '').trim();
const ph = panel && panel.style && String(panel.style.height || '').trim();
if (pl && pt && pw && ph) {
anchor.style.left = pl;
anchor.style.top = pt;
anchor.style.width = pw;
anchor.style.height = ph;
return;
}
if (!mapData || mapData.gameType !== 'quiz_carry') {
clearToFullStack();
return;
}
const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
if (!bounds) {
clearToFullStack();
return;
}
const cc = getQuizCarryMapCameraWorldCenterPxPlay();
const camX = cc ? cc.cx : me.x * tileSize;
const camY = cc ? cc.cy : me.y * tileSize;
const zDom = playDomSyncZoom();
const left = (bounds.minX * tileSize - camX) * zDom + canvas.width / 2;
const top = (bounds.minY * tileSize - camY) * zDom + canvas.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
anchor.style.left = Math.round(left) + 'px';
anchor.style.top = Math.round(top) + 'px';
anchor.style.width = Math.round(Math.max(48, wPx)) + 'px';
anchor.style.height = Math.round(Math.max(40, hPx)) + 'px';
}
function carryEmbedCountdownAnchorResolved() {
if (!mapData || !isQuizCarry()) return 'screen';
const v = mapData.carryEmbedCountdownAnchor;
if (v === 'grid' || v === 'map') return v;
return 'screen';
}
function resetQuizCarryEmbedCountdownLayoutDom() {
const ov = document.getElementById('quiz-carry-embed-countdown');
const inner = document.getElementById('quiz-carry-embed-countdown-inner');
if (ov) ov.classList.remove('quiz-carry-embed-countdown--map-anchor');
if (inner) {
inner.classList.remove('quiz-carry-embed-countdown-inner--map-rect');
inner.style.position = '';
inner.style.left = '';
inner.style.top = '';
inner.style.width = '';
inner.style.height = '';
inner.style.transform = '';
inner.style.maxWidth = '';
inner.style.boxSizing = '';
inner.style.display = '';
inner.style.margin = '';
}
}
/** พรีวิว embed: วางกล่อง 3–2–1 กลางจอ / ชิดโซนทองหรือโซนกลาง / ชิดกริด carryEmbedCountdownArea — ตาม carryEmbedCountdownAnchor */
function syncQuizCarryEmbedCountdownLayout() {
if (!quizCarryEmbedMissionFlowActive() || !mapData) {
resetQuizCarryEmbedCountdownLayoutDom();
return;
}
const ov = document.getElementById('quiz-carry-embed-countdown');
const inner = document.getElementById('quiz-carry-embed-countdown-inner');
const canvasEl = document.getElementById('game-canvas');
if (!ov || !inner || !canvasEl) return;
if (ov.classList.contains('is-hidden')) {
resetQuizCarryEmbedCountdownLayoutDom();
return;
}
const anchor = carryEmbedCountdownAnchorResolved();
if (anchor === 'screen') {
resetQuizCarryEmbedCountdownLayoutDom();
return;
}
let bounds = null;
if (anchor === 'grid') {
bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea);
if (!bounds) {
resetQuizCarryEmbedCountdownLayoutDom();
return;
}
} else {
bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData);
if (!bounds) {
resetQuizCarryEmbedCountdownLayoutDom();
return;
}
}
const cc = getQuizCarryMapCameraWorldCenterPxPlay();
const camX = cc ? cc.cx : me.x * tileSize;
const camY = cc ? cc.cy : me.y * tileSize;
const zDom = playDomSyncZoom();
const l = (bounds.minX * tileSize - camX) * zDom + canvasEl.width / 2;
const t = (bounds.minY * tileSize - camY) * zDom + canvasEl.height / 2;
const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zDom;
const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zDom;
const r = canvasEl.getBoundingClientRect();
const sl = r.left + l;
const st = r.top + t;
ov.classList.add('quiz-carry-embed-countdown--map-anchor');
inner.classList.add('quiz-carry-embed-countdown-inner--map-rect');
inner.style.position = 'fixed';
inner.style.left = Math.round(sl) + 'px';
inner.style.top = Math.round(st) + 'px';
inner.style.width = Math.round(Math.max(100, wPx)) + 'px';
inner.style.height = Math.round(Math.max(72, hPx)) + 'px';
inner.style.transform = 'none';
inner.style.boxSizing = 'border-box';
inner.style.display = 'flex';
inner.style.flexDirection = 'column';
inner.style.alignItems = 'center';
inner.style.justifyContent = 'center';
inner.style.margin = '0';
inner.style.maxWidth = 'none';
}
/** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */
function syncQuizCarryEmbedQuestionStrip() {
const wrap = document.getElementById('quiz-carry-embed-q-strip');
const p = document.getElementById('quiz-carry-embed-q-strip-text');
if (!wrap || !p) return;
wrap.classList.remove('quiz-carry-embed-q-strip--countdown');
wrap.classList.add('is-hidden');
wrap.setAttribute('aria-hidden', 'true');
}
function updatePlayQuizTimerDisplay() {
const el = document.getElementById('quiz-game-timer');
if (!el) return;
if (!playQuizPhaseEndsAt) {
el.textContent = '';
return;
}
const s = Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000));
el.textContent = s + ' วินาที';
}
function clampPreviewMs(n, def, minV, maxV) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(minV, Math.min(maxV, Math.floor(v)));
}
function clampCarrySessionLen(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(500, Math.floor(v)));
}
function buildQuizPoolFromMap(md) {
if (!md || !Array.isArray(md.quizQuestions)) return [];
return md.quizQuestions
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
}
function shufflePreviewQuizQuestionsPlay(arr) {
const a = (arr || []).slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** ตรงกับ server clampQuizRoundQuestionCount — 1–50 ค่าเริ่ม 10 */
function clampPreviewQuizRoundQuestionCount(settings) {
const v = Number(settings && settings.quizRoundQuestionCount);
if (!Number.isFinite(v)) return 10;
return Math.max(1, Math.min(50, Math.floor(v)));
}
function finishPreviewQuizSessionPlay() {
previewQuizStep = 'done';
playQuizPhaseLocal = null;
previewQuizCurrent = null;
playQuizPhaseEndsAt = 0;
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const qov = document.getElementById('quiz-game-overlay');
if (qov) qov.classList.add('is-hidden');
const mapPanel = document.getElementById('quiz-map-question-panel');
if (mapPanel) {
mapPanel.classList.add('is-hidden');
mapPanel.setAttribute('aria-hidden', 'true');
}
if (isQuizQuestionMissionUiMapPlay()) {
quizQuestionMissionPhase = 'ended';
applyQuizQuestionMissionPanelImages();
const mission = quizQuestionMissionBuildPayload();
showGauntletCrownMissionOverlay(mission);
return;
}
playQuizText = 'ครบทุกข้อในรอบทดสอบแล้ว';
if (qov) qov.classList.remove('is-hidden');
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] จบรอบ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'จำนวนข้อต่อรอบใช้ค่าเดียวกับ Admin (quizRoundQuestionCount)';
}
function clearPreviewBotAnswerPaths() {
if (!playBotsEnabled()) return;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
});
}
function resetPreviewBotsQuizState() {
if (!playBotsEnabled()) return;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.quizCannotTrue = false;
o.quizCannotFalse = false;
o.botPath = [];
o.botAnswerWander = false;
});
}
function applyPreviewReadPhase(q, poolLen) {
clearPreviewBotAnswerPaths();
previewQuizStep = 'read';
playQuizPhaseLocal = 'read';
previewQuizCurrent = q;
playQuizText = q.text;
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs;
playQuizQuestionIndex = previewQuizQIndex + 1;
playQuizQuestionTotal = Math.max(0, Number(poolLen) || 0);
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) {
phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · ข้อ ' + (previewQuizQIndex + 1) + ' / ' + poolLen;
}
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ';
}
function applyPreviewAnswerPhase() {
previewQuizStep = 'answer';
playQuizPhaseLocal = 'answer';
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด';
const leg = document.getElementById('quiz-play-legend');
if (leg && previewQuizCurrent) {
leg.textContent = previewQuizCurrent.answerTrue
? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า'
: 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู';
}
previewBotsPrepareAnswerRound();
}
function applyPreviewBetweenPhase() {
clearPreviewBotAnswerPaths();
previewQuizStep = 'between';
playQuizPhaseLocal = null;
playQuizText = 'ข้อถัดไปกำลังจะมา…';
playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'รอสักครู่แล้วไปข้อถัดไป (สับแล้วตามจำนวนข้อต่อรอบ)';
}
function quizCellOnPlay(grid, x, y) {
return !!(grid && grid[y] && grid[y][x] === 1);
}
/** ทั้ง footprint ทับโซน จริง/เท็จ — หลังตอบผิดควรถูกดีดออกแล้ว ไม่วาดทับ (กันจุดเล็กค้างกลาง SAFE/SCAM) */
function playQuizPeerFootprintOverlapsAnswerZone(ax, ay) {
if (!mapData || !isQuiz()) return false;
for (const k of quizTilesFootprintPlay(ax, ay)) {
const parts = k.split(',');
const fx = +parts[0];
const fy = +parts[1];
if (quizCellOnPlay(mapData.quizTrueArea, fx, fy) || quizCellOnPlay(mapData.quizFalseArea, fx, fy)) return true;
}
return false;
}
/** ไม่วาดตัวที่โดนล็อก/เคยตอบผิดแล้วแต่ยังทับโซน (กันจุดเล็กค้าง + desync มัลติเพลย์) */
function playQuizAvatarHideWhileOverlappingAnswerZone(ent, entId, ax, ay) {
if (!playQuizPeerFootprintOverlapsAnswerZone(ax, ay)) return false;
if (entId === myId || ent === me) {
return !!(playQuizPlayerLocal && playQuizPlayerLocal.cannotTrue && playQuizPlayerLocal.cannotFalse);
}
const locked = !!(ent && ent.quizCannotTrue && ent.quizCannotFalse);
const everWrong = !!playQuizEverWrong[String(entId)];
return locked || everWrong;
}
function spawnTileWalkablePlay(md, x, y) {
const w = md.width || 20;
const h = md.height || 15;
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
if (row && row[x] === 1) return false;
return true;
}
function spawnFootprintFitsPlay(md, anchorX, anchorY) {
if (!spawnTileWalkablePlay(md, anchorX, anchorY)) return false;
const cellsW = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsW) || Number(md.characterCells) || 1)));
const cellsH = Math.max(1, Math.min(4, Math.floor(Number(md.characterCellsH) || Number(md.characterCells) || 1)));
const w = md.width || 20;
const h = md.height || 15;
const maxX = Math.min(w, anchorX + cellsW);
const maxY = Math.min(h, anchorY + cellsH);
for (let ty = anchorY; ty < maxY; ty++) {
for (let tx = anchorX; tx < maxX; tx++) {
if (!spawnTileWalkablePlay(md, tx, ty)) return false;
}
}
return true;
}
/** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */
function pickRandomSpawnFromMapPlay(md) {
const fallback = md.spawn || { x: 1, y: 1 };
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
const w = md.width || 20;
const h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && spawnFootprintFitsPlay(md, x, y)) pool.push({ x, y });
}
}
if (!pool.length) return { x: fx, y: fy };
const u = new Uint32Array(1);
(typeof crypto !== 'undefined' && crypto.getRandomValues)
? crypto.getRandomValues(u)
: (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0));
const pick = pool[u[0] % pool.length];
return { x: pick.x, y: pick.y };
}
function parseLobbyPlayerSpawnsFromMapPlay(md) {
const w = md.width || 20;
const h = md.height || 15;
const out = [null, null, null, null, null, null];
const raw = md && md.lobbyPlayerSpawns;
if (!Array.isArray(raw)) return out;
for (let i = 0; i < 6 && i < raw.length; i++) {
const cell = raw[i];
if (!cell || typeof cell !== 'object') continue;
const x = Math.floor(Number(cell.x));
const y = Math.floor(Number(cell.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
if (x < 0 || x >= w || y < 0 || y >= h) continue;
out[i] = { x, y };
}
return out;
}
/** jump_survive: ช่อง P1–P6 ที่วาดแบบยาน (shooterSpawnSlots) เติมใน slots6 เมื่อ lobbyPlayerSpawns ว่าง */
function augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots6) {
if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
const g = md.shooterSpawnSlots;
if (!g) return;
const w = md.width || 20, h = md.height || 15;
for (let slot = 1; slot <= 6; slot++) {
const idx = slot - 1;
if (slots6[idx]) continue;
let found = false;
for (let yy = 0; yy < h && !found; yy++) {
const row = g[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (row[xx] === slot) {
slots6[idx] = { x: xx, y: yy };
found = true;
break;
}
}
}
}
}
/** สอดคล้องกับ 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 (spawnFootprintFitsPlay(md, x, y)) return { x, y };
return pickRandomSpawnFromMapPlay(md);
}
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMapPlay(md);
augmentLobbySlotsFromShooterPaintJumpSurvivePlay(md, slots);
const pick = slots[j];
if (pick && spawnFootprintFitsPlay(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
return pickRandomSpawnFromMapPlay(md);
}
return pickRandomSpawnFromMapPlay(md);
}
const GAUNTLET_PREVIEW_MAX = 6;
function gauntletSpawnYsPlay(md, playerCount) {
const h = md.height || 15;
const lo = 1;
const hi = Math.max(lo, h - 2);
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
if (hi <= lo) return Array.from({ length: n }, () => lo);
const ys = [];
for (let i = 0; i < n; i++) {
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
}
return ys;
}
function collectGauntletSpawnSlotsFromSpawnAreaPlay(md) {
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return [];
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) slots.push({ x, y });
}
}
slots.sort((a, b) => a.y - b.y || a.x - b.x);
return slots;
}
function collectGauntletSpawnSlotsPlay(md) {
const explicit = md.gauntletPlayerSpawns;
if (Array.isArray(explicit) && explicit.length > 0) {
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (const raw of explicit) {
if (slots.length >= GAUNTLET_PREVIEW_MAX) break;
const x = Math.floor(Number(raw && raw.x));
const y = Math.floor(Number(raw && raw.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
if (!spawnTileWalkablePlay(md, x, y)) continue;
slots.push({ x, y });
}
if (slots.length > 0) return slots;
}
return collectGauntletSpawnSlotsFromSpawnAreaPlay(md);
}
function gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotXFallback) {
const w = md.width || 20;
if (spawnTileWalkablePlay(md, spawnColX, y)) return spawnColX;
if (slotXFallback != null && spawnTileWalkablePlay(md, slotXFallback, y)) return slotXFallback;
for (let d = 0; d < w; d++) {
if (spawnColX + d < w && spawnTileWalkablePlay(md, spawnColX + d, y)) return spawnColX + d;
if (spawnColX - d >= 0 && spawnTileWalkablePlay(md, spawnColX - d, y)) return spawnColX - d;
}
return Math.max(0, Math.min(w - 1, spawnColX));
}
function gauntletSpawnPositionsPlay(md, playerCount) {
const n = Math.min(GAUNTLET_PREVIEW_MAX, Math.max(1, playerCount));
const slots = collectGauntletSpawnSlotsPlay(md);
const fallbackYs = gauntletSpawnYsPlay(md, n);
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
const out = [];
for (let i = 0; i < n; i++) {
const y = i < slots.length
? slots[i].y
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
const slotX = i < slots.length ? slots[i].x : null;
const x = gauntletResolveSpawnXForRowPlay(md, spawnColX, y, slotX);
out.push({ x, y });
}
return out;
}
/**
* โหมดทดสอบพรมแดง: จัดตำแหน่งเกิดให้ตรง server (spawnArea หรือกระจาย y)
* @param {boolean} [onlyBots=false] ถ้า true = จัดแค่บอท (หลัง game-start; ไม่ทับ me/คนจริงจาก peersSnap)
*/
function applyGauntletPreviewSpawnLayout(onlyBots) {
if (!mapData || mapData.gameType !== 'gauntlet') return;
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
const humanCount = 1 + realIds.length;
const total = Math.min(GAUNTLET_PREVIEW_MAX, humanCount + botIds.length);
const pos = gauntletSpawnPositionsPlay(mapData, total);
if (onlyBots) {
let idx = humanCount;
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
return;
}
let idx = 0;
if (idx < pos.length) {
me.x = pos[idx].x;
me.y = pos[idx].y;
me.tx = me.x;
me.ty = me.y;
idx++;
}
realIds.forEach((rid) => {
const o = others.get(rid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || idx >= pos.length) return;
o.x = pos[idx].x;
o.tx = pos[idx].x;
o.y = pos[idx].y;
o.ty = pos[idx].y;
idx++;
});
}
function isPreviewBotId(id) {
return typeof id === 'string' && id.indexOf(PREVIEW_BOT_PREFIX) === 0;
}
function countPlayHumans() {
let n = 1;
others.forEach((_, id) => { if (!isPreviewBotId(id)) n++; });
return n;
}
function rebalancePreviewBots() {
if (!playBotsEnabled() || !mapData) return;
const human = countPlayHumans();
const wantBots = Math.max(0, playBotTargetHeadcount() - human);
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o) return;
o.playTint = resolvePlayTintForPeer(bid);
if (o.botTier == null) {
const tierRoll = Math.random();
const stackPrev = mapData && mapData.gameType === 'stack';
o.botTier = stackPrev
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
: (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
}
if (o.quizCannotTrue == null) o.quizCannotTrue = false;
if (o.quizCannotFalse == null) o.quizCannotFalse = false;
if (!Array.isArray(o.botPath)) o.botPath = [];
if (o.botAnswerWander == null) o.botAnswerWander = false;
if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
if (o.gauntletScore == null) {
o.gauntletScore = (mapData && mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) ? 100 : 0;
}
if (o.gauntletEliminated == null) o.gauntletEliminated = false;
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
o.botWanderDx = wd[0];
o.botWanderDy = wd[1];
}
if (typeof o.botWanderNextTurn !== 'number') {
o.botWanderNextTurn = Date.now() + 400 + Math.floor(Math.random() * 900);
}
});
let botIds = [...others.keys()].filter(isPreviewBotId);
while (botIds.length > wantBots) {
const drop = botIds.pop();
if (drop) others.delete(drop);
}
botIds = [...others.keys()].filter(isPreviewBotId);
while (botIds.length < wantBots) {
const id = PREVIEW_BOT_PREFIX + (++previewBotSeq);
const joinIdx = countPlayHumans() + botIds.length;
let x;
let y;
if (mapData.gameType === 'jump_survive') {
const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, joinIdx);
x = pos.x;
y = pos.y;
} else {
const sp = pickSpawnForJoinPlay(mapData, joinIdx);
const jx = (Math.random() - 0.5) * 0.4;
const jy = (Math.random() - 0.5) * 0.4;
x = sp.x + 0.5 + jx;
y = sp.y + 0.5 + jy;
}
if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(x, y);
x = pathSnap.x;
y = pathSnap.y;
}
const tierRoll = Math.random();
const stackPrev = mapData && mapData.gameType === 'stack';
const botTier = stackPrev
? (tierRoll < 0.4 ? 'sharp' : tierRoll < 0.78 ? 'avg' : 'weak')
: (tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak');
const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)];
const crownStart = mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay();
others.set(id, {
x, y, tx: x, ty: y,
direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)],
nickname: 'บอท',
characterId: pickPreviewBotCharacterId(botIds.length),
spawnJoinOrder: joinIdx,
playTint: resolvePlayTintForPeer(id),
botTier,
gauntletJumpTicks: 0,
gauntletJumpVis: 0,
gauntletScore: crownStart ? 100 : 0,
gauntletEliminated: false,
quizCannotTrue: false,
quizCannotFalse: false,
quizCarryHeld: null,
botPath: [],
botAnswerWander: false,
botWanderDx: wd[0],
botWanderDy: wd[1],
botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000),
jumpSurviveEliminated: false,
spaceShooterScore: 0,
balloonBossScore: 0,
balloonBossBossDmg: 0,
balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5,
balloonBossEliminated: false,
botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
});
botIds.push(id);
}
let k = 0;
[...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => {
const o = others.get(bid);
if (!o) return;
const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)';
o.nickname = 'บอท ' + (++k) + ' ' + tag;
});
[...others.keys()].filter(isPreviewBotId).sort().forEach((bid, botIdx) => {
const o = others.get(bid);
if (o) o.characterId = pickPreviewBotCharacterId(botIdx);
});
if (mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData)) {
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o) return;
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
o.x = s.x;
o.y = s.y;
o.tx = s.x;
o.ty = s.y;
});
}
if (mapData.gameType === 'quiz_carry') {
const t0 = performance.now();
[...others.keys()].filter(isPreviewBotId).forEach((bid, idx) => {
const o = others.get(bid);
if (!o) return;
o.botQuizCarryPathfindAfter = t0 + idx * 90 + Math.floor(Math.random() * 140);
});
}
if (mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(false);
emitGauntletPreviewRowsToServer();
}
if (mapData.gameType === 'stack') {
applyStackPreviewSpawnLayout();
rebuildStackPreviewTurnOrder();
}
if (mapData.gameType === 'jump_survive') {
applyJumpSurvivePreviewSpawnLayout(false);
}
if (mapData.gameType === 'space_shooter') {
applySpaceShooterSpawnLayoutPlay();
}
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
}
if (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
try {
preloadPlayOccupantsTints();
preloadPlayLobbyIdleAvatarsForOccupants();
} catch (e) { /* ignore */ }
}
function pickRandomWalkableCellInAnswerGrid(grid, o) {
if (!grid || !mapData) return null;
const w = mapData.width || 20, h = mapData.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] !== 1) continue;
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y });
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
/** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */
function previewBotsPrepareAnswerRound() {
if (!playBotsEnabled() || !mapData || !isQuiz() || !previewQuizCurrent) return;
const correctTrue = !!previewQuizCurrent.answerTrue;
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botPath = [];
o.botAnswerWander = false;
const tier = o.botTier || 'avg';
let pCorrect = 0.52;
if (tier === 'sharp') pCorrect = 0.84;
else if (tier === 'weak') pCorrect = 0.22;
if (Math.random() < 0.14) {
o.botAnswerWander = true;
return;
}
const wantsCorrect = Math.random() < pCorrect;
const targetTrue = wantsCorrect ? correctTrue : !correctTrue;
const zoneGrid = targetTrue ? qt : qf;
const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o);
if (!dest) {
o.botAnswerWander = true;
return;
}
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
if (!path || path.length <= 1) {
o.botAnswerWander = true;
return;
}
o.botPath = path.slice(1);
});
}
/** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */
function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) {
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y);
const walkable = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return row && row[x] !== 1;
};
const fx = Math.floor(Number(sx));
const fy = Math.floor(Number(sy));
if (!walkable(fx, fy)) {
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
const q = [[fx, fy]];
const seen = new Set([`${fx},${fy}`]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = `${nx},${ny}`;
if (seen.has(k)) continue;
if (!walkable(nx, ny)) continue;
seen.add(k);
if (!inAnswer(nx, ny)) {
return { x: nx + 0.5, y: ny + 0.5 };
}
q.push([nx, ny]);
}
}
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
function quizResolveChoiceFromStanding(ox, oy, correctTrue) {
const tx = Math.floor(ox);
const ty = Math.floor(oy);
const qt = mapData.quizTrueArea;
const qf = mapData.quizFalseArea;
const inT = quizCellOnPlay(qt, tx, ty);
const inF = quizCellOnPlay(qf, tx, ty);
let choice = null;
if (inT && !inF) choice = true;
else if (inF && !inT) choice = false;
else if (inT && inF) choice = true;
const right = choice !== null && choice === correctTrue;
return { choice, right };
}
/** ลำดับจุดเกิดเดียวกับ pickSpawnForJoinPlay — ห้ามสุ่มทั้ง spawnArea หลังตอบผิด (จะหลุดจาก P1–P6) */
function quizPreviewJoinOrderForRespawn(actorId) {
if (actorId === myId) {
const jm = Number(me && me.spawnJoinOrder);
return Number.isFinite(jm) ? Math.max(0, Math.floor(jm)) : 0;
}
const o = others.get(actorId);
if (o && typeof o.spawnJoinOrder === 'number' && Number.isFinite(o.spawnJoinOrder)) {
return Math.max(0, Math.floor(o.spawnJoinOrder));
}
if (!isPreviewBotId(actorId)) {
const othersReal = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const idx = othersReal.indexOf(actorId);
return idx >= 0 ? 1 + idx : 1;
}
const bots = [...others.keys()].filter(isPreviewBotId).sort();
const bi = bots.indexOf(actorId);
if (bi < 0) return 0;
return countPlayHumans() + bi;
}
/** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */
function resolvePreviewQuizRound() {
if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return;
const correctTrue = !!previewQuizCurrent.answerTrue;
const results = [];
const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue);
let right = mine.right;
let choice = mine.choice;
if (mine.right) {
playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
} else {
playQuizPlayerLocal.cannotTrue = true;
playQuizPlayerLocal.cannotFalse = true;
const ordMe = quizPreviewJoinOrderForRespawn(myId);
const sp = pickSpawnForJoinPlay(mapData, ordMe);
const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
me.x = pos.x;
me.y = pos.y;
me.tx = me.x;
me.ty = me.y;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
results.push({
id: myId,
right,
choice,
score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0,
});
if (playBotsEnabled()) {
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue);
o.botPath = [];
o.botAnswerWander = false;
if (br.right) {
playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + QUIZ_TF_POINTS_PER_CORRECT;
} else {
o.quizCannotTrue = true;
o.quizCannotFalse = true;
const ordB = quizPreviewJoinOrderForRespawn(id);
const sp = pickSpawnForJoinPlay(mapData, ordB);
const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5);
o.x = pos.x + (Math.random() - 0.5) * 0.35;
o.y = pos.y + (Math.random() - 0.5) * 0.35;
o.tx = o.x;
o.ty = o.y;
}
results.push({
id,
right: br.right,
choice: br.choice,
score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0,
});
});
}
const r = { results, scores: { ...playLiveQuizScores } };
results.forEach(function (row) {
if (!row || row.id == null) return;
if (!row.right) playQuizEverWrong[String(row.id)] = true;
});
renderPlayQuizScoreboard(playLiveQuizScores);
if (results.some(function (row) { return row && row.right; })) {
spawnQuizTrueFalseScorePopupPlay(correctTrue);
}
showPlayQuizFeedback(r);
}
function advancePreviewQuizIfDue() {
if (isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) return;
if (previewQuizStep === 'done') return;
if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return;
const pool = previewQuizPool;
if (!pool || !pool.length) return;
if (previewQuizStep === 'read') {
applyPreviewAnswerPhase();
return;
}
if (previewQuizStep === 'answer') {
resolvePreviewQuizRound();
applyPreviewBetweenPhase();
return;
}
if (previewQuizStep === 'between') {
previewQuizQIndex += 1;
if (previewQuizQIndex >= pool.length) {
finishPreviewQuizSessionPlay();
return;
}
const q = pool[previewQuizQIndex];
if (q) applyPreviewReadPhase(q, pool.length);
}
}
async function loadPreviewQuizAndStart() {
resetPreviewBotsQuizState();
playQuizEverWrong = {};
let settings = null;
try {
const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
if (r.ok) settings = await r.json();
} catch (e) { /* use map fallback */ }
let pool = [];
if (settings && Array.isArray(settings.questions) && settings.questions.length) {
pool = settings.questions
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }));
}
if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData);
const cap = clampPreviewQuizRoundQuestionCount(settings);
const shuffled = shufflePreviewQuizQuestionsPlay(pool);
previewQuizPool = shuffled.slice(0, Math.min(cap, shuffled.length));
previewQuizQIndex = 0;
const dRead = 10000;
const dAns = 5000;
const dBet = 3500;
previewQuizTiming = {
readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000),
answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000),
betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000),
};
previewQuizCurrent = null;
if (!pool.length) {
playQuizPhaseLocal = 'read';
previewQuizStep = 'read';
playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)';
playQuizPhaseEndsAt = 0;
playQuizQuestionIndex = 0;
playQuizQuestionTotal = 0;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const leg = document.getElementById('quiz-play-legend');
if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ';
initPlayLiveQuizScoresZeros();
startPlayQuizTimer();
return;
}
const session = previewQuizPool;
const q = session[0];
if (q) applyPreviewReadPhase(q, session.length);
initPlayLiveQuizScoresZeros();
startPlayQuizTimer();
}
function startPlayQuizTimer() {
if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
playQuizTimerInterval = setInterval(() => {
updatePlayQuizTimerDisplay();
advancePreviewQuizIfDue();
}, 200);
updatePlayQuizTimerDisplay();
}
function teardownPlayQuizUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
const panel = document.getElementById('quiz-map-question-panel');
const mapQText = document.getElementById('quiz-map-question-text');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
clearQuizMapPanelThemeInline(panel, mapQText);
}
playQuizPhaseLocal = null;
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
playQuizText = '';
playQuizPhaseEndsAt = 0;
playQuizQuestionIndex = 0;
playQuizQuestionTotal = 0;
previewQuizPool = [];
previewQuizQIndex = 0;
previewQuizCurrent = null;
previewQuizStep = 'read';
playLiveQuizScores = {};
playQuizEverWrong = {};
quizTfScorePopups = [];
playPath = [];
hidePlayQuizHud();
quizCarryJoinSettingsSnap = null;
quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
quizCarryMapPanelTheme = null;
quizMapPanelTheme = null;
quizCarryChoicePlaqueThemes = null;
resetQuizCarryPlayState();
resetQuizBattlePlayState();
quizQuestionMissionPhase = null;
quizQuestionMissionDeferredPhase = null;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
const gcmTeardown = document.getElementById('gauntlet-crown-mission-overlay');
if (gcmTeardown) gcmTeardown.classList.add('is-hidden');
cancelEmbedPreviewLobbyReturnTimer();
const grabBtnTeardown = document.getElementById('quiz-carry-grab-btn');
if (grabBtnTeardown) {
grabBtnTeardown.classList.add('is-hidden');
grabBtnTeardown.classList.remove('quiz-carry-grab-btn--active');
grabBtnTeardown.classList.remove('quiz-carry-grab-btn--place');
grabBtnTeardown.style.pointerEvents = '';
const im = grabBtnTeardown.querySelector('img');
if (im) im.src = '/Game/img/quiz-carry/btn-grab.png';
}
const gchJumpTeardown = document.getElementById('gauntlet-crown-jump-btn');
if (gchJumpTeardown) {
gchJumpTeardown.classList.add('is-hidden');
gchJumpTeardown.setAttribute('aria-hidden', 'true');
gchJumpTeardown.style.pointerEvents = '';
}
const stackDropTeardown = document.getElementById('stack-tower-drop-btn');
if (stackDropTeardown) {
stackDropTeardown.classList.add('is-hidden');
stackDropTeardown.setAttribute('aria-hidden', 'true');
stackDropTeardown.style.pointerEvents = '';
stackDropTeardown.classList.remove('stack-tower-drop-btn--active');
}
hideStackTowerResultFlashDomOnlyPlay();
}
function setupPlayQuizUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) {
const hideBottomQuizBar = (previewMode && editorEmbedReturn && !isQuizCarry())
|| (isQuiz() && isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live');
if (hideBottomQuizBar) ov.classList.add('is-hidden');
else ov.classList.remove('is-hidden');
}
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
const leg = document.getElementById('quiz-play-legend');
if (previewMode) {
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…';
if (leg) leg.textContent = '';
if (isQuizQuestionMissionUiMapPlay()) {
if (phaseEl) phaseEl.textContent = '[ทดสอบ] รอกด READY เพื่อเริ่มภารกิจ';
if (qEl) qEl.textContent = 'โหมดภารกิจ — หน้าปก HOWTO จาก /img/QUESTION/';
if (leg) leg.textContent = '';
} else {
loadPreviewQuizAndStart();
}
} else {
playQuizPhaseLocal = null;
playQuizText = 'รอคำถามจากโฮสต์…';
playQuizPhaseEndsAt = 0;
playQuizQuestionIndex = 0;
playQuizQuestionTotal = 0;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)';
if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby';
const tEl = document.getElementById('quiz-game-timer');
if (tEl) tEl.textContent = '';
}
if (isQuiz() && !isQuizCarry()) {
(async () => {
try {
let s = {};
const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
if (r.ok) {
const raw = await r.text();
const trimmed = (raw || '').trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
s = JSON.parse(trimmed);
} catch (e) { /* ignore */ }
}
}
s = await mergeQuizCarrySettingsFromDisk(s && typeof s === 'object' ? s : {});
setQuizMapPanelThemeFromApi(s);
try {
syncPlayQuizMapPanel();
} catch (e2) { /* ignore */ }
} catch (e) { /* ignore */ }
})();
}
}
/** อัปเดตแถบคำถาม quiz จากเซิร์ฟเวอร์ — แยกไว้ให้ภารกิจ mng8a80o เรียกหลังจบ countdown */
function applyQuizPhaseFromServer(p) {
if (!p || !mapData || !isQuiz()) return;
if (p.text) playQuizText = p.text;
playQuizPhaseLocal = p.phase;
playQuizPhaseEndsAt = p.endsAt || 0;
playQuizQuestionIndex = Math.max(0, Number(p.questionIndex) || 0);
playQuizQuestionTotal = Math.max(0, Number(p.questionTotal) || 0);
const phaseEl = document.getElementById('quiz-game-phase-label');
const qEl = document.getElementById('quiz-game-question');
if (phaseEl) {
phaseEl.textContent = p.phase === 'read'
? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''))
: ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || ''));
}
if (qEl) qEl.textContent = playQuizText || '';
const ov = document.getElementById('quiz-game-overlay');
if (isQuizQuestionMissionHudActivePlay()) {
if (ov) ov.classList.add('is-hidden');
} else if (ov) {
ov.classList.remove('is-hidden');
}
if (playQuizTimerInterval) clearInterval(playQuizTimerInterval);
playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200);
updatePlayQuizTimerDisplay();
if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros();
if (!previewMode && isQuiz() && p.phase === 'read' && Number(p.questionIndex) === 1) playQuizEverWrong = {};
try {
syncPlayQuizMapPanel();
} catch (eQm) { /* ignore */ }
}
function isFrogger() { return mapData && mapData.gameType === 'frogger'; }
function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; }
function isGauntletFaceRightMapMno9kb07() {
if (!isGauntlet()) return false;
/** ใช้ currentPlayMapId (session → mapData.id → ?map=) ให้สอดคล้อง runway / timing — กัน HUD crown หลุดค้างเป็นแผงเก่าแนวตั้งทับ TIME */
return (currentPlayMapId() || '').trim() === GAUNTLET_FACE_RIGHT_MAP_ID;
}
/** Crown heist rules + UI (เดียวกับหันขวา — ฉาก mno9kb07) */
function isGauntletCrownHeistMapPlay() {
return isGauntletFaceRightMapMno9kb07();
}
function isMegaVirusMissionShellMapPlay() {
return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID);
}
/** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */
function usesCrownLobbyShellPlay() {
return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay();
}
/** Last Light (mno9kb07) embed — ค่าเริ่มต้นเมื่อโหลดแมป (ปรับซูมได้ด้วยล้อ / [ ]) */
const PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL = 1.49;
/** Stack Tower (mnn93hpi) ใน embed พรีวิว: ซูมคงที่ ×1 — ล้อ/ปุ่มไม่ปรับได้ */
const PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL = 1;
function isStackTowerEmbedZoomLockedPlay() {
return !!(previewMode && editorEmbedReturn && mapData && isStackTowerMissionUiMapPlay());
}
function applyPlayEmbedZoomForCurrentMapPlay() {
if (isStackTowerEmbedZoomLockedPlay()) {
playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
} else if (previewMode && editorEmbedReturn && mapData && isGauntletCrownHeistMapPlay()) {
playEmbedUserZoomMul = PLAY_EMBED_LAST_LIGHT_DEFAULT_ZOOM_MUL;
}
}
/** smoothstep 0..1 */
function stackTowerPost50SmoothstepPlay(t) {
const x = Math.max(0, Math.min(1, t));
return x * x * (3 - 2 * x);
}
/** Tower live + progress ≥เกณฑ์: 0→1 ระหว่าง STACK_TOWER_POST50_ANIM_MS — ใช้เฉพาะเมื่อเปิดกล้องตามหอ (ไม่งั้นฉากธรรมดาไม่เลื่อน/ซูมหลังเกณฑ์) */
function getStackTowerPost50AnimEasePlay() {
if (!getStackTowerCameraFollowPlayConfig().enabled) {
stackTowerPost50AnimStartMs = null;
return 0;
}
if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'live' || !stackMini) return 0;
const p = Number(stackMini.progressPct) || 0;
if (p < STACK_TOWER_POST50_PROGRESS_THRESH) {
stackTowerPost50AnimStartMs = null;
return 0;
}
if (stackTowerPost50AnimStartMs == null) stackTowerPost50AnimStartMs = performance.now();
const u = Math.min(1, Math.max(0, (performance.now() - stackTowerPost50AnimStartMs) / STACK_TOWER_POST50_ANIM_MS));
return stackTowerPost50SmoothstepPlay(u);
}
function getStackTowerPost50ZMulPlay() {
const e = getStackTowerPost50AnimEasePlay();
if (e <= 0) return 1;
return 1 + (STACK_TOWER_POST50_ZOOM_MUL - 1) * e;
}
function getStackTowerPost50BgScrollExtraPxPlay(ch) {
const e = getStackTowerPost50AnimEasePlay();
if (e <= 0) return 0;
const chR = Math.max(1, Number(ch) || 720);
return STACK_TOWER_POST50_MAP_SHIFT_SCREEN_FRAC * chR * e;
}
/**
* Same zDraw chain as draw() (zoom, special maps, Last Light embed lock, preview embed mul).
* Runway end checks must not rely on lastPlayZDrawForInput alone — it can lag map/canvas or default 1.4 vs real zoom.
*/
function computePlayCameraZDrawPlay() {
if (!canvas || !mapData) return zoom;
const w = mapData.width || 20;
const h = mapData.height || 15;
let zDraw = zoom;
if (isSpaceShooter() || isBalloonBoss()) {
const mwPx = w * tileSize;
const mhPx = h * tileSize;
zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
} else if (isQuizQuestionMissionHudActivePlay()) {
const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
if (qmc) {
zDraw = Math.min(canvas.width / qmc.mwPx, canvas.height / qmc.mhPx) * 0.96;
}
} else if (isQuizCarry()) {
const mwPx = w * tileSize;
const mhPx = h * tileSize;
zDraw = Math.min(canvas.width / mwPx, canvas.height / mhPx) * 0.96;
}
if (isStackTowerEmbedZoomLockedPlay()) {
playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
}
if (previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()) {
zDraw *= playEmbedUserZoomMul;
}
if (isStackTowerMissionUiMapPlay()) {
zDraw *= STACK_TOWER_FIXED_RENDER_Z_MUL;
zDraw *= getStackTowerPost50ZMulPlay();
}
return zDraw;
}
let gauntletCrownHowtoVisible = false;
function isGauntletCrownPregameBlockingPlay() {
if (!usesCrownLobbyShellPlay()) return false;
if (gauntletCrownRunHeldRemote) return true;
if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true;
return false;
}
/**
* Last Light (crown heist): center camera on the whole party bbox instead of only `me`
* — reduces empty void on the side when you fall behind and keeps bots + you in frame together when possible.
*/
function getGauntletCrownHeistGroupCameraCenterPxPlay(tileSizeRef, canvasW, canvasH, zDrawVal) {
if (!isGauntletCrownHeistMapPlay() || !mapData) return null;
const md = mapData;
const { cw, ch } = getCharacterFootprintWH(md);
const ww = md.width || 20;
const hh = md.height || 15;
const mapWpx = ww * tileSizeRef;
const mapHpx = hh * tileSizeRef;
const halfW = canvasW / (2 * zDrawVal);
const halfH = canvasH / (2 * zDrawVal);
const ents = [];
if (!me.gauntletEliminated && Number.isFinite(me.x) && Number.isFinite(me.y)) {
ents.push({ cx: (me.x + cw * 0.5) * tileSizeRef, cy: (me.y + ch * 0.5) * tileSizeRef });
}
others.forEach((o, id) => {
if (!o || o.gauntletEliminated) return;
if (!playBotsEnabled() && isPreviewBotId(id)) return;
if (!Number.isFinite(o.x) || !Number.isFinite(o.y)) return;
ents.push({ cx: (o.x + cw * 0.5) * tileSizeRef, cy: (o.y + ch * 0.5) * tileSizeRef });
});
if (!ents.length) return null;
let minCx = Infinity;
let maxCx = -Infinity;
let sumCy = 0;
for (let i = 0; i < ents.length; i++) {
const e = ents[i];
minCx = Math.min(minCx, e.cx);
maxCx = Math.max(maxCx, e.cx);
sumCy += e.cy;
}
let px = (minCx + maxCx) * 0.5;
let py = sumCy / ents.length;
const minCamX = halfW;
const maxCamX = Math.max(minCamX, mapWpx - halfW);
const minCamY = halfH;
const maxCamY = Math.max(minCamY, mapHpx - halfH);
const viewW = halfW * 2;
const viewH = halfH * 2;
/*
* ถ้ามุมมองกว้าง/สูงกว่าแมป: อย่าใช้ clamp แบบ minCamX (= กล้องชิดซ้ายโลก) — จะดูเหมือนแมปติดมุมซ้ายบน (เด่นใน embed / ซูมออก)
* ให้จัดกลางแมปแทน — ภารกิจคำถาม mng8a80o ช่วง live ใช้กล้องกลางแมปเหมือนกัน (ดู isQuizQuestionMissionHudActivePlay + draw)
*/
if (viewW >= mapWpx) px = mapWpx * 0.5;
else px = Math.max(minCamX, Math.min(maxCamX, px));
if (viewH >= mapHpx) py = mapHpx * 0.5;
else py = Math.max(minCamY, Math.min(maxCamY, py));
return { px, py };
}
/** Preview bots only — full tail-off loss needs server support for real humans */
const GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX = 40;
function maybeEliminateGauntletPreviewBotsTailOffCameraPlay(camX, camY, zDrawVal) {
if (!playBotsEnabled() || !isGauntletCrownHeistMapPlay() || !mapData) return;
if (gauntletCrownPregamePhase !== 'live') return;
const halfW = canvas.width / (2 * zDrawVal);
const worldMinX = camX - halfW;
const gate = worldMinX - GAUNTLET_CROWN_TAIL_OFFSCREEN_MARGIN_PX;
const { cw } = getCharacterFootprintWH(mapData);
const ts = mapData.tileSize || 32;
others.forEach((o, id) => {
if (!isPreviewBotId(id) || !o || o.gauntletEliminated) return;
const rightWorld = (Number(o.x) + cw) * ts;
if (rightWorld < gate) o.gauntletEliminated = true;
});
}
function isStack() { return mapData && mapData.gameType === 'stack'; }
function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; }
function currentPlayMapId() {
const q = (playSessionMapId || '').trim();
if (q) return q;
if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim();
return (playMapIdFromQuery || '').trim();
}
function isQuizQuestionMissionUiMapPlay() {
return isQuiz() && currentPlayMapId() === QUIZ_QUESTION_MISSION_MAP_ID;
}
function isQuizQuestionMissionPregameBlockingPlay() {
if (!isQuizQuestionMissionUiMapPlay()) return false;
return quizQuestionMissionPhase != null && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended';
}
function isStackTowerMissionUiMapPlay() {
return isStack() && currentPlayMapId() === STACK_TOWER_MISSION_MAP_ID;
}
function getStackTowerBlockWorldScalePlay() {
return isStackTowerMissionUiMapPlay() ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
}
function isStackTowerMissionPregameBlockingPlay() {
if (!isStackTowerMissionUiMapPlay()) return false;
return stackTowerMissionPhase != null && stackTowerMissionPhase !== 'live' && stackTowerMissionPhase !== 'ended';
}
function isStackTowerMissionHudActivePlay() {
return isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
}
/** mnptfts2 Jumper — ใช้ cyber HUD แบบภารกิจคำถาม (TIME plaque + SCORE แถว QM + กรอบโปรไฟล์) */
function isJumpSurviveMissionHudActivePlay() {
return isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'live';
}
/** Space Shooter mnpz6rkp — ช่วงเล่นจริง: HUD เดียวกับ Stack mission (คลาส question-mission + TIME plaque) */
function isSpaceShooterMissionHudActivePlay() {
return isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'live';
}
function stackTowerAssetUrl(file) {
return BASE + '/img/TowerBlock/' + String(file || '').replace(/^\/+/, '');
}
function ensureStackTowerSlingImagePlay() {
if (!stackTowerSlingImg) {
stackTowerSlingImg = new Image();
stackTowerSlingImg.decoding = 'async';
stackTowerSlingImg.src = stackTowerAssetUrl('sling.png');
}
const im = stackTowerSlingImg;
return im && im.complete && im.naturalWidth > 0 ? im : null;
}
function ensureStackTowerScorePopupImagesPlay() {
if (!stackTowerScorePlus10Img) {
stackTowerScorePlus10Img = new Image();
stackTowerScorePlus10Img.decoding = 'async';
stackTowerScorePlus10Img.src = stackTowerAssetUrl('score+10.png');
}
if (!stackTowerScorePlus20Img) {
stackTowerScorePlus20Img = new Image();
stackTowerScorePlus20Img.decoding = 'async';
stackTowerScorePlus20Img.src = stackTowerAssetUrl('score+20.png');
}
return { p10: stackTowerScorePlus10Img, p20: stackTowerScorePlus20Img };
}
function ensureStackTowerPerfectFxImagesPlay() {
if (!stackTowerScoreLightImg) {
stackTowerScoreLightImg = new Image();
stackTowerScoreLightImg.decoding = 'async';
stackTowerScoreLightImg.src = stackTowerAssetUrl('score-light.png');
}
if (!stackTowerScoreX2Img) {
stackTowerScoreX2Img = new Image();
stackTowerScoreX2Img.decoding = 'async';
stackTowerScoreX2Img.src = stackTowerAssetUrl('scorex2.png');
}
return { light: stackTowerScoreLightImg, x2: stackTowerScoreX2Img };
}
/** score-light หลัง scorex2 — วาดเป็นคู่ข้างชั้นบน (mock รูป2) */
function drawStackTowerPerfectX2ClusterPlay(ctx, lightImg, x2Img, sx, sy, drawW, drawH, nowT, fxUntil) {
if (!x2Img || !x2Img.complete || !x2Img.naturalWidth || drawW <= 0 || drawH <= 0) return;
const iw = x2Img.naturalWidth;
const ih = x2Img.naturalHeight;
const targetH = Math.max(drawH * 0.92, Math.min(drawH * 1.35, 72));
const scale = targetH / ih;
const dw = iw * scale;
const dh = ih * scale;
const cx = sx + drawW + drawW * 0.06;
const cy = sy + drawH * 0.5;
const life = fxUntil != null ? Math.max(0, Math.min(1, (fxUntil - nowT) / STACK_TOWER_PERFECT_X2_MS)) : 1;
const pulse = 0.4 + 0.48 * Math.sin((1 - life) * Math.PI);
if (lightImg && lightImg.complete && lightImg.naturalWidth) {
const lp = 1.06;
const lw = dw * lp;
const lh = dh * lp;
const lnx = lightImg.naturalWidth;
const lny = lightImg.naturalHeight;
ctx.save();
ctx.globalAlpha = Math.min(0.9, pulse + 0.12);
ctx.globalCompositeOperation = 'screen';
try {
ctx.drawImage(lightImg, 0, 0, lnx, lny, cx - lw * 0.5, cy - lh * 0.5, lw, lh);
} catch (e) { /* ignore */ }
ctx.restore();
}
ctx.save();
ctx.globalAlpha = 0.98;
ctx.globalCompositeOperation = 'source-over';
try {
ctx.drawImage(x2Img, 0, 0, iw, ih, cx - dw * 0.48, cy - dh * 0.5, dw, dh);
} catch (e) { /* ignore */ }
ctx.restore();
}
function drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY) {
if (!m.scorePopups || !m.scorePopups.length) return;
const now = performance.now();
const dur = STACK_TOWER_SCORE_POPUP_MS;
const imgs = ensureStackTowerScorePopupImagesPlay();
m.scorePopups = m.scorePopups.filter((p) => p.until > now);
const hidPop = getStackTowerVisualHiddenLayerCountPlay();
for (let pi = 0; pi < m.scorePopups.length; pi++) {
const p = m.scorePopups[pi];
const li = Math.floor(Number(p.layerIndex)) || 0;
if (li < 0 || li >= m.layers.length) continue;
if (li < hidPop) continue;
const L = m.layers[li];
const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * getStackTowerBlockWorldScalePlay();
const cxW = L.cx * tileSize;
const yb = floorY - (li + 1) * layerWorldH;
const xl = cxW - lw / 2;
const [sx, sy] = worldToScreen(xl, yb);
const drawW = lw * zoom;
const drawH = layerWorldH * zoom;
const t0 = p.until - dur;
const t = Math.max(0, Math.min(1, (now - t0) / dur));
const alpha = 0.2 + 0.78 * Math.sin(Math.PI * t);
const rise = Math.sin((t * Math.PI) / 2) * 20 * zoom;
const im = p.kind === '20' ? imgs.p20 : imgs.p10;
const targetW = Math.min(drawW * 0.92, Math.max(52, 64 * zoom));
let iw = targetW;
let ih = targetW * 0.38;
if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
ih = (targetW * im.naturalHeight) / im.naturalWidth;
}
const cx = sx + drawW / 2;
const cy = sy - Math.max(8, 6 * zoom) - rise - ih * 0.5;
ctx.save();
ctx.globalAlpha = Math.min(0.98, Math.max(0.08, alpha));
if (im && im.complete && im.naturalWidth > 0) {
try {
ctx.drawImage(im, 0, 0, im.naturalWidth, im.naturalHeight, cx - iw / 2, cy - ih / 2, iw, ih);
} catch (e) { /* ignore */ }
} else {
ctx.font = 'bold ' + Math.round(Math.max(12, 15 * zoom)) + 'px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = p.kind === '20' ? '#7af8ff' : '#b4f9cd';
ctx.fillText(p.kind === '20' ? '+20' : '+10', cx, cy);
}
ctx.restore();
}
}
function ensureStackTowerHeartMinusImgPlay() {
if (!stackTowerHeartMinusImg) {
stackTowerHeartMinusImg = new Image();
stackTowerHeartMinusImg.decoding = 'async';
stackTowerHeartMinusImg.src = stackTowerAssetUrl('heart-1.png');
}
const im = stackTowerHeartMinusImg;
return im && im.complete && im.naturalWidth > 0 ? im : null;
}
function ensureStackTowerLifeHudImagesPlay() {
if (!stackTowerLifeBarImg) {
stackTowerLifeBarImg = new Image();
stackTowerLifeBarImg.decoding = 'async';
stackTowerLifeBarImg.src = stackTowerAssetUrl('life-bar.png');
}
if (!stackTowerLifeHitImg) {
stackTowerLifeHitImg = new Image();
stackTowerLifeHitImg.decoding = 'async';
stackTowerLifeHitImg.src = stackTowerAssetUrl('life-hit.png');
}
if (!stackTowerLifeFullImg) {
stackTowerLifeFullImg = new Image();
stackTowerLifeFullImg.decoding = 'async';
stackTowerLifeFullImg.src = stackTowerAssetUrl('life-full.png');
}
}
/**
* สัดส่วนสูงของ life-bar.png ที่เป็นข้อความ "SYSTEM INTEGRITY" (ที่เหลือ = แถวหัวใจแยกวาดเอง ไม่ทับขอบ `[` `]` ใน PNG)
*/
const STACK_TOWER_LIFE_BAR_TITLE_FRAC = 0.38;
/**
* วาดหัวข้อจาก life-bar.png (ครอปบน) + แถวหัวใจ life-full / life-hit กึ่งกลาง — คืนความสูงที่ใช้จริง
*/
function drawStackTowerLifeIntegrityBarPlay(ctx, destX, destY, maxW, maxH, lifeSlots, livesRem) {
ensureStackTowerLifeHudImagesPlay();
const bar = stackTowerLifeBarImg;
const fullIm = (stackTowerLifeFullImg && stackTowerLifeFullImg.complete && stackTowerLifeFullImg.naturalWidth > 0)
? stackTowerLifeFullImg
: ensureStackTowerHeartMinusImgPlay();
const hitIm = stackTowerLifeHitImg && stackTowerLifeHitImg.complete && stackTowerLifeHitImg.naturalWidth > 0
? stackTowerLifeHitImg
: null;
const slots = Math.max(1, Math.min(20, Math.floor(lifeSlots) || 1));
const lives = Math.max(0, Math.min(slots, Math.floor(livesRem) || 0));
if (!bar || !bar.complete || !bar.naturalWidth || maxW <= 4 || maxH <= 4) {
ctx.save();
ctx.fillStyle = '#bb9af7';
ctx.font = '600 9px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let heartStr = '';
for (let i = 0; i < slots; i++) heartStr += i < lives ? '♥ ' : '♡ ';
ctx.fillText('SYSTEM INTEGRITY ' + heartStr.trim(), destX, destY + maxH * 0.5);
ctx.restore();
return Math.min(maxH, 28);
}
const bw0 = bar.naturalWidth;
const bh0 = bar.naturalHeight;
const titleSrcH = Math.max(2, Math.floor(bh0 * STACK_TOWER_LIFE_BAR_TITLE_FRAC));
const scale = Math.min(maxW / bw0, 1.45);
const bw = bw0 * scale;
const titleDh = titleSrcH * scale;
const gap = 3;
const bx = destX;
const by0 = destY + Math.max(0, (maxH - titleDh - gap - Math.min(40, maxH * 0.45)) * 0.35);
const heartsTop = by0 + titleDh + gap;
const roomHearts = Math.max(10, destY + maxH - heartsTop - 2);
const heartsH = Math.max(18, Math.min(roomHearts, 40, Math.floor(maxH * 0.5)));
ctx.save();
try {
ctx.drawImage(bar, 0, 0, bw0, titleSrcH, bx, by0, bw, titleDh);
} catch (e) { /* ignore */ }
const cy = heartsTop + heartsH * 0.5;
const bracketW = Math.min(14, Math.max(9, bw * 0.07));
ctx.fillStyle = 'rgba(94, 239, 255, 0.88)';
ctx.font = '600 ' + Math.round(Math.min(17, heartsH * 0.55)) + 'px ui-monospace, "Cascadia Mono", Consolas, monospace';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.shadowColor = 'rgba(0, 234, 255, 0.45)';
ctx.shadowBlur = 4;
ctx.fillText('[', bx + 1, cy);
ctx.textAlign = 'right';
ctx.fillText(']', bx + bw - 1, cy);
ctx.shadowBlur = 0;
const ix = bx + bracketW + 4;
const iwInner = Math.max(24, bw - (bracketW + 4) * 2);
const slotW = iwInner / slots;
const iconMax = Math.min(heartsH * 0.78, slotW * 0.72, 34);
for (let i = 0; i < slots; i++) {
const cx = ix + slotW * (i + 0.5);
const useFull = i < lives;
const im = useFull ? fullIm : hitIm;
if (im && im.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
const nw = im.naturalWidth;
const nh = im.naturalHeight;
let ih = iconMax;
let iw = (ih * nw) / nh;
const capW = slotW * 0.82;
const capH = heartsH * 0.88;
if (iw > capW) {
iw = capW;
ih = (iw * nh) / nw;
}
if (ih > capH) {
ih = capH;
iw = (ih * nw) / nh;
}
if (iw < 6 && nh > 0) {
iw = 6;
ih = (iw * nh) / nw;
if (ih > capH) {
ih = capH;
iw = (ih * nw) / nh;
}
}
try {
ctx.drawImage(im, 0, 0, nw, nh, cx - iw / 2, cy - ih / 2, iw, ih);
} catch (e2) { /* ignore */ }
} else {
ctx.fillStyle = useFull ? '#9ece6a' : '#565f89';
ctx.font = 'bold ' + Math.round(Math.max(10, iconMax * 0.72)) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(useFull ? '♥' : '♡', cx, cy);
}
}
ctx.restore();
const usedH = heartsTop + heartsH - by0 + 2;
return Math.min(maxH, Math.max(titleDh, usedH));
}
function triggerStackTowerHeartMinusFxPlay() {
if (!isStackTowerMissionHudActivePlay()) return;
stackTowerHeartMinusFx = { until: performance.now() + STACK_TOWER_HEART_MINUS_FX_MS };
}
function drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay) {
if (!stackTowerHeartMinusFx) return;
const nt = performance.now();
if (nt >= stackTowerHeartMinusFx.until) {
stackTowerHeartMinusFx = null;
return;
}
const dur = STACK_TOWER_HEART_MINUS_FX_MS;
const t0 = stackTowerHeartMinusFx.until - dur;
const u = Math.max(0, Math.min(1, (nt - t0) / dur));
const alpha = 1 - u * u;
const worldCx = m.topCenterX * tileSize;
const stackTopY = floorY - nLay * layerWorldH;
const worldCy = stackTopY - tileSize * 1.55 - u * tileSize * 0.5;
const [sx, sy] = worldToScreen(worldCx, worldCy);
const im = ensureStackTowerHeartMinusImgPlay();
const maxW = Math.max(72, Math.min(240, 6.8 * tileSize * zoom));
let dw = maxW;
let dh = maxW * 0.52;
if (im && im.naturalWidth > 0 && im.naturalHeight > 0) {
dh = (maxW * im.naturalHeight) / im.naturalWidth;
}
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(0.98, alpha));
if (im) {
ctx.drawImage(im, sx - dw / 2, sy - dh / 2, dw, dh);
} else {
ctx.fillStyle = '#f7768e';
ctx.font = 'bold ' + Math.round(Math.max(14, 18 * zoom)) + 'px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('♥ −1', sx, sy);
}
ctx.restore();
}
/** Glow สี่เหลี่ยมสีเหลือง — ขอบเขตเท่ากล่องบล็อก (drawW×drawH) ไม่ล้ำออกนอกสไปรต์ */
function drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, nowT, untilT) {
if (drawW <= 0 || drawH <= 0) return;
const life = Math.max(0, Math.min(1, (untilT - nowT) / STACK_TOWER_PERFECT_GLOW_MS));
const pulse = 0.38 + 0.48 * Math.sin((1 - life) * Math.PI);
ctx.save();
ctx.beginPath();
ctx.rect(sx, sy, drawW, drawH);
ctx.clip();
ctx.globalCompositeOperation = 'source-over';
ctx.shadowColor = 'rgba(255, 210, 40, 0.9)';
ctx.shadowBlur = Math.max(3, Math.min(10, 4 + pulse * 5));
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.globalAlpha = Math.min(0.85, 0.4 + pulse * 0.34);
ctx.fillStyle = 'rgba(255, 228, 72, 0.52)';
ctx.fillRect(sx, sy, drawW, drawH);
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
ctx.globalAlpha = Math.min(0.72, 0.26 + pulse * 0.32);
ctx.strokeStyle = 'rgba(255, 250, 200, 0.78)';
const sw = Math.max(1.2, Math.min(2.8, drawW * 0.022));
ctx.lineWidth = sw;
ctx.strokeRect(sx + sw * 0.5, sy + sw * 0.5, drawW - sw, drawH - sw);
ctx.restore();
}
/**
* เชือกจากจุดหนีบบน (จอ) ลงจุดยึดบล็อก — รูป sling แนวตั้ง (แกน Y = สาย) ยืดตามความยาว
*/
function drawStackSlingSegmentPlay(ctx, sx0, sy0, sx1, sy1, zoom) {
const dx = sx1 - sx0;
const dy = sy1 - sy0;
const len = Math.hypot(dx, dy);
if (len < 1.5) return;
const sling = ensureStackTowerSlingImagePlay();
if (sling) {
const nw = sling.naturalWidth;
const nh = sling.naturalHeight;
const ropeW = Math.max(3, Math.min(40, nw * Math.min(1.1, zoom * 0.11) + 2));
const ang = Math.atan2(dy, dx);
ctx.save();
ctx.translate(sx0, sy0);
ctx.rotate(ang - Math.PI / 2);
ctx.globalAlpha = 0.96;
try {
ctx.drawImage(sling, -ropeW / 2, 0, ropeW, len);
} catch (e) { /* ignore */ }
ctx.globalAlpha = 1;
ctx.restore();
} else {
ctx.strokeStyle = 'rgba(90, 88, 86, 0.82)';
ctx.lineWidth = Math.max(2.2, zoom * 1.4);
ctx.beginPath();
ctx.moveTo(sx0, sy0);
ctx.lineTo(sx1, sy1);
ctx.stroke();
ctx.lineWidth = 1;
}
}
const GCHOWTO_STACK_TOWER_CLASS = 'gauntlet-crown-howto-overlay--stack-tower';
function setGauntletCrownHowtoStackTowerArtMaskPlay(on) {
const hov = document.getElementById('gauntlet-crown-howto-overlay');
if (!hov) return;
hov.classList.toggle(GCHOWTO_STACK_TOWER_CLASS, !!on);
}
function hideStackTowerHowtoRulesDom() {
/* กล่องกติกา Stack Tower ถูกถอนออกจาก DOM — เก็บชื่อฟังก์ชันเพื่อไม่ให้จุดเรียกพัง */
}
function teardownStackTowerMissionUiPlay() {
stackTowerMissionPhase = null;
stackTowerMissionDeferredPhase = null;
stackTowerSessionStartAt = 0;
stackTowerMissionEndedOnce = false;
stackTowerPost50AnimStartMs = null;
hideStackTowerHowtoRulesDom();
setGauntletCrownHowtoStackTowerArtMaskPlay(false);
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
hideStackTowerResultFlashDomOnlyPlay();
}
function hideStackTowerResultFlashDomOnlyPlay() {
if (stackTowerResultFlashTimer) {
clearTimeout(stackTowerResultFlashTimer);
stackTowerResultFlashTimer = null;
}
const ov = document.getElementById('stack-tower-result-flash');
const img = document.getElementById('stack-tower-result-flash-img');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
if (img) {
img.onerror = null;
img.removeAttribute('src');
}
}
/** โชว์รูปผล Tower จาก TowerBlock 5 วิ แล้วเปิด GCM สรุปเดิม */
function beginStackTowerResultFlashThenGcm(mission) {
if (!mission || mission.uiSkin !== 'stack_tower') {
showGauntletCrownMissionOverlay(mission);
return;
}
hideStackTowerResultFlashDomOnlyPlay();
const ov = document.getElementById('stack-tower-result-flash');
const imgEl = document.getElementById('stack-tower-result-flash-img');
if (!ov || !imgEl) {
showGauntletCrownMissionOverlay(mission);
return;
}
const outc = String(mission.stackTowerOutcome || 'time_up');
let file = 'result-timeup.png';
if (outc === 'decrypt_complete') file = 'result-complete.png';
else if (outc === 'misses_exhausted') file = 'result-gameover.png';
const url = stackTowerAssetUrl(file);
const finishToGcm = function () {
hideStackTowerResultFlashDomOnlyPlay();
showGauntletCrownMissionOverlay(mission);
};
imgEl.onerror = function () {
imgEl.onerror = null;
finishToGcm();
};
imgEl.src = url;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
stackTowerResultFlashTimer = setTimeout(function () {
stackTowerResultFlashTimer = null;
finishToGcm();
}, STACK_TOWER_RESULT_FLASH_MS);
}
/** Jumper mnptfts2: เกรด F = ภาพ gameover · อื่น = complete (ก่อนแผงสรุปผล) */
function jumpSurviveMissionOutcomeImageFile(mission) {
const g = mission && String(mission.grade || '').toUpperCase();
if (g === 'F') return 'result-gameover.png';
return 'result-complete.png';
}
/**
* Jumper ภารกิจ: ใช้ #stack-tower-result-flash เหมือน Tower — time_up = timeup แล้ว outcome;
* all_dead = ข้าม timeup แล้ว outcome อย่างเดียว แล้วค่อย GCM
*/
function beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, endKind) {
if (!mission || mission.uiSkin !== 'jumper') {
showGauntletCrownMissionOverlay(mission);
return;
}
hideStackTowerResultFlashDomOnlyPlay();
const ov = document.getElementById('stack-tower-result-flash');
const imgEl = document.getElementById('stack-tower-result-flash-img');
if (!ov || !imgEl) {
showGauntletCrownMissionOverlay(mission);
return;
}
const outcomeFile = jumpSurviveMissionOutcomeImageFile(mission);
const finishToGcm = function () {
hideStackTowerResultFlashDomOnlyPlay();
showGauntletCrownMissionOverlay(mission);
};
const showOneFlash = function (file, onDone) {
imgEl.onerror = function () {
imgEl.onerror = null;
onDone();
};
imgEl.src = jumperAssetUrl(file);
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
stackTowerResultFlashTimer = setTimeout(function () {
stackTowerResultFlashTimer = null;
onDone();
}, STACK_TOWER_RESULT_FLASH_MS);
};
if (endKind === 'time_up') {
showOneFlash('result-timeup.png', function () {
hideStackTowerResultFlashDomOnlyPlay();
showOneFlash(outcomeFile, finishToGcm);
});
} else {
showOneFlash(outcomeFile, finishToGcm);
}
}
/**
* Space Shooter mnpz6rkp: หมดเวลา = แฟลช result-timeup แล้ว result-gameover แล้ว GCM;
* ทุกคนตาย = แฟลช result-gameover อย่างเดียวแล้ว GCM
*/
function beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, endKind) {
if (!mission || mission.uiSkin !== 'violent_crime') {
showGauntletCrownMissionOverlay(mission);
return;
}
hideStackTowerResultFlashDomOnlyPlay();
const ov = document.getElementById('stack-tower-result-flash');
const imgEl = document.getElementById('stack-tower-result-flash-img');
if (!ov || !imgEl) {
showGauntletCrownMissionOverlay(mission);
return;
}
const finishToGcm = function () {
hideStackTowerResultFlashDomOnlyPlay();
showGauntletCrownMissionOverlay(mission);
};
const showOneFlash = function (file, onDone) {
imgEl.onerror = function () {
imgEl.onerror = null;
onDone();
};
imgEl.src = violentCrimeAssetUrl(file);
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
stackTowerResultFlashTimer = setTimeout(function () {
stackTowerResultFlashTimer = null;
onDone();
}, STACK_TOWER_RESULT_FLASH_MS);
};
if (endKind === 'time_up') {
showOneFlash('result-timeup.png', function () {
hideStackTowerResultFlashDomOnlyPlay();
showOneFlash('result-gameover.png', finishToGcm);
});
} else {
showOneFlash('result-gameover.png', finishToGcm);
}
}
/**
* Mega Virus (balloon_boss shell): แฟลชรูปเดียวแล้วค่อย GCM
* — แพ้ (ทุกคนตาย / หมดเวลา) = result-gameover.png (TowerBlock เหมือนภารกิจอื่น — รูป2 flow)
* — ชนะ = end-victory-2.png ใน MegaVirus
*/
function beginBalloonBossMegaVirusResultFlashSequenceThenGcm(mission, endReason) {
if (!mission || mission.uiSkin !== 'mega_virus') {
showGauntletCrownMissionOverlay(mission);
return;
}
hideStackTowerResultFlashDomOnlyPlay();
const ov = document.getElementById('stack-tower-result-flash');
const imgEl = document.getElementById('stack-tower-result-flash-img');
if (!ov || !imgEl) {
showGauntletCrownMissionOverlay(mission);
return;
}
const lose = endReason === 'all_dead' || endReason === 'time';
const flashSrc = lose ? stackTowerAssetUrl('result-gameover.png') : megaVirusAssetUrl('end-victory-2.png');
const finishToGcm = function () {
hideStackTowerResultFlashDomOnlyPlay();
showGauntletCrownMissionOverlay(mission);
};
imgEl.onerror = function () {
imgEl.onerror = null;
finishToGcm();
};
imgEl.src = flashSrc;
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
stackTowerResultFlashTimer = setTimeout(function () {
stackTowerResultFlashTimer = null;
finishToGcm();
}, STACK_TOWER_RESULT_FLASH_MS);
}
function stackTowerMissionTimeLimitSecPlay() {
const t = Number(playStackTowerMissionTimeSec);
if (Number.isFinite(t) && t > 0) return Math.max(10, Math.min(7200, Math.floor(t)));
return 90;
}
function stackTowerProgressBlocksPlay() {
const n = Number(playStackTowerProgressBlocks);
if (Number.isFinite(n) && n >= 1) return Math.max(1, Math.min(500, Math.floor(n)));
return 50;
}
function stackTowerDecryptPctPlay() {
if (!stackMini) return 0;
if (isStackTowerMissionUiMapPlay()) {
const p = Number(stackMini.progressPct);
const x = Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0));
if (x >= 99.999) return 100;
return Math.floor(x);
}
const teamScore = stackMini.score || 0;
return Math.min(100, Math.floor((stackMini.layers.length || 0) * 11 + Math.min(40, teamScore * 3)));
}
function stackTowerMissionElapsedSecPlay() {
if (stackTowerSessionStartAt <= 0) return 0;
return (performance.now() - stackTowerSessionStartAt) / 1000;
}
function applyStackTowerMissionPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = stackTowerAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = stackTowerAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
ensureStackTowerSlingImagePlay();
}
function showStackTowerMissionHowtoOverlay() {
if (!isStackTowerMissionUiMapPlay()) return;
hideConflictingOverlaysForGauntletCrown();
applyStackTowerMissionPanelImages();
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
stackTowerMissionPhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
setGauntletCrownHowtoStackTowerArtMaskPlay(true);
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) st.classList.remove('gch-status--jumper');
const btn = document.getElementById('btn-gch-ready');
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
const soleParticipant = totPlayers === 1;
if (soleParticipant) {
if (st) {
st.textContent = '';
st.classList.add('is-hidden');
}
if (btn) {
const canGo = isMePlayHost() || humans.length === 1;
btn.classList.remove('is-start-phase');
btn.classList.toggle('is-read-only', !canGo);
btn.disabled = !canGo;
btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
btn.setAttribute('aria-pressed', 'false');
}
} else {
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
gauntletCrownSyncGuestReadyIfNeeded();
updateStackTowerMissionHowtoHud();
}
}
/** Stack Tower mnn93hpi — Ready Status / ปุ่ม แบบ quiz mission (mng8a80o) */
function updateStackTowerMissionHowtoHud() {
if (!isStackTowerMissionUiMapPlay() || stackTowerMissionPhase !== 'howto') return;
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!st || !btn) return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
/** Jump Survival mnptfts2 — Ready Status / ปุ่ม เดียวกับ Stack Tower */
function updateJumpSurviveMissionHowtoHud() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'howto') return;
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!st || !btn) return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
/** Space Shooter mnpz6rkp — Ready Status / ปุ่ม เดียวกับ Jumper */
function updateSpaceShooterMissionHowtoHud() {
if (!isSpaceShooterMissionUiMapPlay() || spaceShooterMissionPhase !== 'howto') return;
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!st || !btn) return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
function beginStackTowerMissionCountdownThenRun() {
if (!isStackTowerMissionUiMapPlay()) return;
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
stackTowerMissionPhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) {
howto.classList.add('is-hidden');
howto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
}
gauntletCrownHowtoVisible = false;
hideStackTowerHowtoRulesDom();
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.classList.add('is-hidden');
st.classList.remove('gch-status--jumper');
}
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = function () {
if (cd) cd.classList.add('is-hidden');
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
stackTowerMissionPhase = 'live';
stackTowerMissionEndedOnce = false;
stackTowerSessionStartAt = performance.now();
lastStackTickMs = performance.now();
resetStackMinigameState();
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
setCountdown321QuestionAssetGraphic(numEl, n);
const step = function () {
n--;
if (n > 0) {
setCountdown321QuestionAssetGraphic(numEl, n);
stackTowerMissionCountdownTimer = setTimeout(step, 1000);
} else {
stackTowerMissionCountdownTimer = null;
runFinish();
}
};
stackTowerMissionCountdownTimer = setTimeout(step, 1000);
}
function stackTowerMissionBuildPayload(endOpts) {
const outcome = endOpts && endOpts.outcome ? String(endOpts.outcome) : 'decrypt_complete';
const rows = [];
const teamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
function pushEnt(id, nickname, characterId, score) {
rows.push({
id: id,
nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
characterId: characterId ?? null,
baseScore: Math.max(0, Number(score) || 0),
eliminated: false,
hadQuizWrong: false,
rankBonus: 0,
finalScore: Math.max(0, Number(score) || 0),
});
}
if (playBotsEnabled() && stackPreviewTurnOrder && stackPreviewTurnOrder.length === STACK_PREVIEW_TURN_COUNT) {
if (myId != null) {
pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.stackPreviewHumanPts || 0);
}
stackPreviewTurnOrder.forEach(function (entry) {
if (!entry || entry.kind !== 'bot' || !entry.botId) return;
const o = others.get(entry.botId);
pushEnt(entry.botId, o ? o.nickname : entry.botId, o ? o.characterId : null, o ? (o.stackBotScore || 0) : 0);
});
} else {
if (myId != null) {
pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), teamPts);
}
others.forEach(function (o, id) {
if (!o) return;
if (playBotsEnabled() && isPreviewBotId(id)) return;
pushEnt(id, o.nickname, o.characterId, teamPts);
});
}
const seen = new Set();
const uniq = [];
rows.forEach(function (r) {
const sid = String(r.id);
if (seen.has(sid)) return;
seen.add(sid);
uniq.push(r);
});
uniq.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = uniq.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: false,
hadQuizWrong: false,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: row.baseScore,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (totalSum <= 0 && uniq.length) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'stack_tower',
survivorCount: uniq.length,
participantCount: uniq.length,
stackTowerOutcome: outcome,
};
}
function stackTowerMissionMergePreviewBotsMission(mission) {
if (!mission || mission.uiSkin !== 'stack_tower' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: false,
hadQuizWrong: false,
rankBonus: 0,
finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: Math.max(0, Number(o.stackBotScore) || 0),
eliminated: false,
hadQuizWrong: false,
rankBonus: 0,
finalScore: Math.max(0, Number(o.stackBotScore) || 0),
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: false,
hadQuizWrong: false,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n2 = ranked.length || 1;
const averageScore = Math.floor(totalSum / n2);
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (totalSum <= 0 && baseRows.length) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'stack_tower',
survivorCount: baseRows.length,
participantCount: baseRows.length,
stackTowerOutcome: mission.stackTowerOutcome,
};
}
function stackTowerMissionMaybeEndPlay() {
if (!isStackTowerMissionUiMapPlay()) return;
if (stackTowerMissionPhase !== 'live') return;
if (stackTowerMissionEndedOnce) return;
const pct = stackTowerDecryptPctPlay();
const elapsed = stackTowerMissionElapsedSecPlay();
const timeLim = stackTowerMissionTimeLimitSecPlay();
const missesOut = stackMini && stackMini.teamMissesLeft != null && stackMini.teamMissesLeft <= 0;
const timeOut = elapsed >= timeLim;
if (pct < 100 && !timeOut && !missesOut) return;
stackTowerMissionEndedOnce = true;
stackTowerMissionPhase = 'ended';
applyStackTowerMissionPanelImages();
let outcome = 'time_up';
if (pct >= 100) outcome = 'decrypt_complete';
else if (missesOut) outcome = 'misses_exhausted';
beginStackTowerResultFlashThenGcm(stackTowerMissionBuildPayload({ outcome: outcome }));
}
function isJumpSurviveMissionUiMapPlay() {
return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID;
}
function isJumpSurviveMissionPregameBlockingPlay() {
if (!isJumpSurviveMissionUiMapPlay()) return false;
return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended';
}
function jumperAssetUrl(file) {
return BASE + '/img/Jumper/' + file;
}
function hideConflictingOverlaysForJumpSurviveMission() {
hideConflictingOverlaysForGauntletCrown();
}
function jumpSurviveTimeLimitSecForMission() {
const m = Number(mapData && mapData.jumpSurviveTimeSec);
if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m);
const j = Number(playJumpSurviveMissionTimeSec);
if (Number.isFinite(j) && j > 0 && j < 7200) return Math.floor(j);
return 60;
}
function jumpSurviveRemainingSecMission() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null;
const lim = jumpSurviveTimeLimitSecForMission();
if (!(lim > 0)) return null;
const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function applyJumpSurviveJumperPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = jumperAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = jumperAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
}
function showJumpSurviveMissionHowtoOverlay() {
if (!isJumpSurviveMissionUiMapPlay()) return;
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
jumpSurviveMissionPhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) st.classList.remove('gch-status--jumper');
const btn = document.getElementById('btn-gch-ready');
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
const soleParticipant = totPlayers === 1;
if (soleParticipant) {
if (st) {
st.textContent = '';
st.classList.add('is-hidden');
}
if (btn) {
const canGo = humans.length === 1 || isMePlayHost();
btn.classList.remove('is-start-phase');
btn.classList.toggle('is-read-only', !canGo);
btn.disabled = !canGo;
btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
btn.setAttribute('aria-pressed', 'false');
}
} else {
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
gauntletCrownSyncGuestReadyIfNeeded();
updateJumpSurviveMissionHowtoHud();
}
}
function beginJumpSurviveMissionCountdownThenRun() {
if (!isJumpSurviveMissionUiMapPlay()) return;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
jumpSurviveMissionPhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.classList.add('is-hidden');
st.classList.remove('gch-status--jumper');
}
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = function () {
if (cd) cd.classList.add('is-hidden');
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
jumpSurviveMissionPhase = 'live';
jumpSurviveGameEnded = false;
jumpSurviveEliminated = false;
applyJumpSurvivePreviewSpawnLayout(false);
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
setCountdown321QuestionAssetGraphic(numEl, n);
const step = function () {
n--;
if (n > 0) {
setCountdown321QuestionAssetGraphic(numEl, n);
jumperMissionCountdownTimer = setTimeout(step, 1000);
} else {
jumperMissionCountdownTimer = null;
runFinish();
}
};
jumperMissionCountdownTimer = setTimeout(step, 1000);
}
/** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */
function jumpSurviveGradeFromSurvivorCount(survivors) {
const s = Math.max(0, Math.floor(Number(survivors) || 0));
if (s >= 6) return 'A';
if (s === 5) return 'B';
if (s === 4) return 'C';
if (s === 3) return 'D';
if (s === 2) return 'E';
return 'F';
}
function jumpSurviveRollRewardCardForGrade(grade) {
if (grade === 'F') return null;
const r = Math.random();
if (grade === 'A' || grade === 'B') {
if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'C' || grade === 'D') {
if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'E') {
if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' };
if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
function jumpSurviveMissionTotalParticipants() {
let n = myId != null ? 1 : 0;
if (others && typeof others.size === 'number') n += others.size;
return n;
}
function jumpSurviveMissionCountAlive() {
let n = 0;
if (myId != null && !jumpSurviveEliminated) n++;
if (others && typeof others.forEach === 'function') {
others.forEach(function (o) {
if (o && !o.jumpSurviveEliminated) n++;
});
}
return n;
}
/** จบทันทีเมื่อไม่มีผู้รอด — เหลือคนสุดท้ายยังเล่นต่อจนหมดเวลา (ไม่จบเร็วแบบ “ชนะขาดลอย”) */
function jumpSurviveMissionMaybeEarlyFinish() {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
const alive = jumpSurviveMissionCountAlive();
if (alive === 0) {
endJumpSurviveMissionRound('all_dead');
}
}
function jumpSurviveBuildMissionPayload() {
const rows = [];
const pushEnt = function (id, nickname, characterId, eliminated) {
const alive = !eliminated;
const baseScore = alive ? 100 : 0;
rows.push({
id: id,
nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
characterId: characterId ?? null,
baseScore: baseScore,
eliminated: !!eliminated,
rankBonus: 0,
finalScore: baseScore,
});
};
if (myId != null) {
pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated);
}
others.forEach(function (o, id) {
if (!o) return;
pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated);
});
rows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
let survivorCount = 0;
for (let si = 0; si < rows.length; si++) {
if (rows[si] && !rows[si].eliminated) survivorCount++;
}
const participantCount = rows.length;
const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
const top = missionEnsureHumanInTopRanked(rows, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: row.baseScore,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const rewardCard = jumpSurviveRollRewardCardForGrade(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'jumper',
survivorCount: survivorCount,
participantCount: participantCount,
};
}
function jumpSurviveMergePreviewBotsMission(mission) {
if (!mission || mission.uiSkin !== 'jumper' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
rankBonus: 0,
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: o.jumpSurviveEliminated ? 0 : 100,
eliminated: !!o.jumpSurviveEliminated,
rankBonus: 0,
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
let survivorCount = 0;
for (let si = 0; si < baseRows.length; si++) {
if (baseRows[si] && !baseRows[si].eliminated) survivorCount++;
}
const participantCount = baseRows.length;
const grade = jumpSurviveGradeFromSurvivorCount(survivorCount);
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: jumpSurviveRollRewardCardForGrade(grade),
totalParts: totalParts,
uiSkin: 'jumper',
survivorCount: survivorCount,
participantCount: participantCount,
};
}
function endJumpSurviveMissionRound(endKind) {
if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return;
jumpSurviveGameEnded = true;
jumpSurviveMissionPhase = 'ended';
applyJumpSurviveJumperPanelImages();
const mission = jumpSurviveBuildMissionPayload();
const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
beginJumpSurviveMissionResultFlashSequenceThenGcm(mission, kind);
}
function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; }
function isSpaceShooterMissionUiMapPlay() {
return isSpaceShooter() && currentPlayMapId() === SPACE_SHOOTER_MISSION_MAP_ID;
}
function isSpaceShooterMissionPregameBlockingPlay() {
if (!isSpaceShooterMissionUiMapPlay()) return false;
return spaceShooterMissionPhase != null && spaceShooterMissionPhase !== 'live' && spaceShooterMissionPhase !== 'ended';
}
function violentCrimeAssetUrl(file) {
return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, '');
}
function megaVirusAssetUrl(file) {
return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, '');
}
function applyMegaVirusMissionPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = megaVirusAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = megaVirusAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
}
function applySpaceShooterMissionPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = violentCrimeAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = violentCrimeAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
}
function showSpaceShooterMissionHowtoOverlay() {
if (!isSpaceShooterMissionUiMapPlay()) return;
hideConflictingOverlaysForGauntletCrown();
applySpaceShooterMissionPanelImages();
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
spaceShooterMissionPhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) st.classList.remove('gch-status--jumper');
const btn = document.getElementById('btn-gch-ready');
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
const soleParticipant = totPlayers === 1;
if (soleParticipant) {
if (st) {
st.textContent = '';
st.classList.add('is-hidden');
}
if (btn) {
const canGo = humans.length === 1 || isMePlayHost();
btn.classList.remove('is-start-phase');
btn.classList.toggle('is-read-only', !canGo);
btn.disabled = !canGo;
btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
btn.setAttribute('aria-pressed', 'false');
}
} else {
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
gauntletCrownSyncGuestReadyIfNeeded();
updateSpaceShooterMissionHowtoHud();
}
spaceShooterResetMissionShipStateForAllPlay();
}
function beginSpaceShooterMissionCountdownThenRun() {
if (!isSpaceShooterMissionUiMapPlay()) return;
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
spaceShooterMissionPhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.classList.add('is-hidden');
st.classList.remove('gch-status--jumper');
}
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = function () {
if (cd) cd.classList.add('is-hidden');
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
spaceShooterMissionPhase = 'live';
spaceShooterGameEnded = false;
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterAsteroidExplosions = [];
spaceShooterPopups = [];
spaceShooterLastTickMs = performance.now();
spaceShooterSpawnAccMs = 0;
spaceShooterFireCd = 0;
spaceShooterSessionStartMs = performance.now();
spaceShooterLastMoveEmit = 0;
if (myId != null) me.spaceShooterScore = 0;
others.forEach(function (o) {
if (o) o.spaceShooterScore = 0;
});
spaceShooterResetMissionShipStateForAllPlay();
if (mapData) applySpaceShooterSpawnLayoutPlay();
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
setCountdown321QuestionAssetGraphic(numEl, n);
const step = function () {
n--;
if (n > 0) {
setCountdown321QuestionAssetGraphic(numEl, n);
spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
} else {
spaceShooterMissionCountdownTimer = null;
runFinish();
}
};
spaceShooterMissionCountdownTimer = setTimeout(step, 1000);
}
function spaceShooterBuildMissionPayload() {
const rows = [];
function pushEnt(id, nickname, characterId, score, eliminated) {
rows.push({
id: id,
nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
characterId: characterId ?? null,
baseScore: Math.max(0, Number(score) || 0),
eliminated: !!eliminated,
rankBonus: 0,
finalScore: Math.max(0, Number(score) || 0),
});
}
if (myId != null) {
pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), me.spaceShooterScore, !!me.spaceShooterEliminated);
}
others.forEach(function (o, id) {
if (!o) return;
pushEnt(id, o.nickname, o.characterId, o.spaceShooterScore, !!o.spaceShooterEliminated);
});
rows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = rows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: row.baseScore,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const survivorCount = rows.filter(function (r) { return !r.eliminated; }).length;
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (survivorCount <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'violent_crime',
survivorCount: survivorCount,
participantCount: rows.length,
};
}
function spaceShooterMergePreviewBotsMission(mission) {
if (!mission || mission.uiSkin !== 'violent_crime' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
rankBonus: 0,
finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: Math.max(0, Number(o.spaceShooterScore) || 0),
eliminated: !!o.spaceShooterEliminated,
rankBonus: 0,
finalScore: Math.max(0, Number(o.spaceShooterScore) || 0),
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n2 = ranked.length || 1;
const averageScore = Math.floor(totalSum / n2);
const survivorCount = baseRows.filter(function (r) { return !r.eliminated; }).length;
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (survivorCount <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'violent_crime',
survivorCount: survivorCount,
participantCount: baseRows.length,
};
}
function balloonBossMergePreviewBotsMission(mission) {
if (!mission || mission.uiSkin !== 'mega_virus' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
rankBonus: 0,
finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: Math.max(0, Number(o.balloonBossScore) || 0),
eliminated: !!o.balloonBossEliminated,
rankBonus: 0,
finalScore: Math.max(0, Number(o.balloonBossScore) || 0),
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n2 = ranked.length || 1;
const averageScore = Math.floor(totalSum / n2);
const maxHp = balloonBossMaxHpPlay();
let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
others.forEach(function (o) {
if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
});
const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
const score100 = Math.min(100, Math.floor(progress * 100));
let grade = 'C';
const endR = mission && mission.balloonBossEndReason;
if (endR === 'victory' || progress >= 1) grade = 'A';
else if (endR === 'all_dead') grade = 'F';
else if (score100 >= 50) grade = 'B';
else if (totalSum <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'mega_virus',
survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length,
participantCount: baseRows.length,
balloonBossEndReason: mission.balloonBossEndReason,
};
}
function balloonBossBuildMissionPayloadPlay(reason) {
const rows = [];
if (myId != null) {
rows.push({
id: myId,
nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'),
characterId: me.characterId ?? null,
baseScore: Math.max(0, me.balloonBossScore | 0),
eliminated: !!me.balloonBossEliminated,
});
}
others.forEach((o, id) => {
rows.push({
id: id,
nickname: (o && o.nickname) ? String(o.nickname).trim() : id,
characterId: o && o.characterId != null ? o.characterId : null,
baseScore: Math.max(0, o.balloonBossScore | 0),
eliminated: !!(o && o.balloonBossEliminated),
});
});
rows.sort((a, b) => b.baseScore - a.baseScore
|| String(a.nickname).localeCompare(String(b.nickname), 'th')
|| String(a.id).localeCompare(String(b.id)));
const top = rows.slice(0, 5);
const ranked = top.map((row, idx) => {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map((r) => r.baseScore);
const totalSum = totalParts.reduce((s, n) => s + n, 0);
const n2 = ranked.length || 1;
const averageScore = Math.floor(totalSum / n2);
const maxHp = balloonBossMaxHpPlay();
let bossDmgSum = Math.max(0, me.balloonBossBossDmg | 0);
others.forEach((o) => {
if (o) bossDmgSum += Math.max(0, o.balloonBossBossDmg | 0);
});
const progress = Math.min(1, bossDmgSum / Math.max(1, maxHp));
const score100 = Math.min(100, Math.floor(progress * 100));
let grade = 'C';
if (reason === 'victory' || progress >= 1) grade = 'A';
else if (reason === 'all_dead') grade = 'F';
else if (score100 >= 50) grade = 'B';
else if (totalSum <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'mega_virus',
survivorCount: rows.filter((r) => !r.eliminated).length,
participantCount: rows.length,
balloonBossEndReason: reason,
};
}
function endSpaceShooterMissionRound(endKind) {
if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return;
const kind = endKind === 'all_dead' ? 'all_dead' : 'time_up';
spaceShooterGameEnded = true;
spaceShooterMissionPhase = 'ended';
applySpaceShooterMissionPanelImages();
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterAsteroidExplosions = [];
spaceShooterSpawnAccMs = 0;
spaceShooterPopups = [];
const mission = spaceShooterBuildMissionPayload();
mission.spaceShooterEndKind = kind;
beginSpaceShooterMissionResultFlashSequenceThenGcm(mission, kind);
}
function questionMissionAssetUrl(file) {
return BASE + '/img/QUESTION/' + String(file || '').replace(/^\//, '');
}
/** HUD กลางจอ (เวลา / แผ่นคำถาม) — ชื่อไฟล์คู่กับ public/img/QUESTION บนดิสก์ → URL /Game/img/QUESTION */
function questionMissionHudAssetUrl(file) {
return questionMissionAssetUrl(file);
}
function isQuizQuestionMissionHudActivePlay() {
return isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'live';
}
/** ภารกิจคำถาม mng8a80o ช่วง live: กล้องกลางแมป (world px) — ไม่ตาม me */
function getQuizQuestionMissionMapCenterWorldPxPlay() {
if (!mapData || !isQuiz()) return null;
const w = mapData.width || 20;
const h = mapData.height || 15;
const ts = tileSize;
const mwPx = w * ts;
const mhPx = h * ts;
return { cx: mwPx * 0.5, cy: mhPx * 0.5, mwPx, mhPx };
}
function applyQuizQuestionMissionPanelImages() {
const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg');
if (howtoBg) {
howtoBg.src = questionMissionAssetUrl('popup-Howto.png');
howtoBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-Howto.png';
};
}
const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg');
if (resBg) {
resBg.src = questionMissionAssetUrl('popup-result.png');
resBg.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/popup-result.png';
};
}
}
function showQuizQuestionMissionHowtoOverlay() {
if (!isQuizQuestionMissionUiMapPlay()) return;
hideConflictingOverlaysForGauntletCrown();
applyQuizQuestionMissionPanelImages();
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
quizQuestionMissionPhase = 'howto';
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) st.classList.remove('gch-status--jumper');
const btn = document.getElementById('btn-gch-ready');
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
/** ผู้เล่นจริงในห้องคนเดียว และไม่มีบอท/peer อื่นในนับรวม — เท่านั้นที่ข้าม lobby */
const soleParticipant = totPlayers === 1;
if (soleParticipant) {
if (st) {
st.textContent = '';
st.classList.add('is-hidden');
}
if (btn) {
const canGo = isMePlayHost() || humans.length === 1;
btn.classList.remove('is-start-phase');
btn.classList.toggle('is-read-only', !canGo);
btn.disabled = !canGo;
btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host';
btn.setAttribute('aria-pressed', 'false');
}
} else {
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-sync-request');
gauntletCrownSyncGuestReadyIfNeeded();
updateQuizQuestionMissionHowtoHud();
}
}
function beginQuizQuestionMissionCountdownThenRun() {
if (!isQuizQuestionMissionUiMapPlay()) return;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
quizQuestionMissionPhase = 'countdown';
const howto = document.getElementById('gauntlet-crown-howto-overlay');
if (howto) howto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
const st = document.getElementById('gauntlet-crown-howto-status');
if (st) {
st.classList.add('is-hidden');
st.classList.remove('gch-status--jumper');
}
const cd = document.getElementById('gauntlet-crown-countdown');
const numEl = document.getElementById('gauntlet-crown-countdown-num');
const runFinish = function () {
if (cd) cd.classList.add('is-hidden');
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
quizQuestionMissionPhase = 'live';
playEmbedUserZoomMul = 1;
const qov = document.getElementById('quiz-game-overlay');
if (qov) {
if (isQuizQuestionMissionUiMapPlay()) qov.classList.add('is-hidden');
else if (previewMode && editorEmbedReturn) qov.classList.add('is-hidden');
else qov.classList.remove('is-hidden');
}
const dp = quizQuestionMissionDeferredPhase;
quizQuestionMissionDeferredPhase = null;
if (dp) applyQuizPhaseFromServer(dp);
if (previewMode && isQuiz()) loadPreviewQuizAndStart();
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
setCountdown321QuestionAssetGraphic(numEl, n);
const step = function () {
n--;
if (n > 0) {
setCountdown321QuestionAssetGraphic(numEl, n);
quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
} else {
quizQuestionMissionCountdownTimer = null;
runFinish();
}
};
quizQuestionMissionCountdownTimer = setTimeout(step, 1000);
}
function quizQuestionMissionBuildPayload() {
const rows = [];
function pushEnt(id, nickname, characterId, score) {
const sid = String(id);
rows.push({
id: id,
nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id),
characterId: characterId ?? null,
baseScore: Math.max(0, Number(score) || 0),
eliminated: false,
hadQuizWrong: !!playQuizEverWrong[sid],
rankBonus: 0,
finalScore: Math.max(0, Number(score) || 0),
});
}
if (myId != null) {
pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), playLiveQuizScores[myId]);
}
others.forEach(function (o, id) {
if (!o) return;
pushEnt(id, o.nickname, o.characterId, playLiveQuizScores[id]);
});
rows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = rows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: row.baseScore,
eliminated: false,
hadQuizWrong: !!row.hadQuizWrong,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: row.baseScore,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (totalSum <= 0 && rows.length) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'question_mission',
survivorCount: rows.length,
participantCount: rows.length,
};
}
function quizQuestionMissionMergePreviewBotsMission(mission) {
if (!mission || mission.uiSkin !== 'question_mission' || !playBotsEnabled() || !others || typeof others.forEach !== 'function') return mission;
const seen = new Set();
(mission.ranked || []).forEach(function (r) {
if (r && r.id != null) seen.add(String(r.id));
});
const baseRows = (mission.ranked || []).map(function (r) {
return {
id: r.id,
nickname: r.nickname,
characterId: r.characterId,
baseScore: Math.max(0, Number(r.baseScore) || 0),
eliminated: false,
hadQuizWrong: !!r.hadQuizWrong,
rankBonus: 0,
finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 0),
};
});
others.forEach(function (o, id) {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
eliminated: false,
hadQuizWrong: !!playQuizEverWrong[sid],
rankBonus: 0,
finalScore: Math.max(0, Number(playLiveQuizScores[id]) || 0),
});
});
baseRows.sort(function (a, b) {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map(function (row, idx) {
const pos = idx + 1;
const bs = Math.max(0, Number(row.baseScore) || 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: false,
hadQuizWrong: !!row.hadQuizWrong,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus: 0,
finalScore: bs,
};
});
const totalParts = ranked.map(function (r) { return r.baseScore; });
const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0);
const n2 = ranked.length || 1;
const averageScore = Math.floor(totalSum / n2);
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (totalSum <= 0 && baseRows.length) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked: ranked,
totalSum: totalSum,
averageScore: averageScore,
grade: grade,
rewardCard: rewardCard,
totalParts: totalParts,
uiSkin: 'question_mission',
survivorCount: baseRows.length,
participantCount: baseRows.length,
};
}
function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; }
function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; }
/** พรีวิว editor + ภารกิจสืบสวน mnorwqx1 — flow เต็ม: HOWTO/READY, นับ 3-2-1, ครบ carrySessionLength, สรุปภารกิจ */
function quizCarryEmbedMissionFlowActive() {
if (!isQuizCarry()) return false;
if (previewMode && editorEmbedReturn) return true;
return !!(isDetectiveMinigamePlay() && quizCarryUseMissionSummaryOverlay);
}
/** quiz_carry: จุดกลางแมป (world px) — กล้องไม่ตามตัวละคร; ใช้ร่วม draw + overlay DOM */
function getQuizCarryMapCameraWorldCenterPxPlay() {
if (!mapData || mapData.gameType !== 'quiz_carry') return null;
const ww = mapData.width || 20, hh = mapData.height || 15;
const ts = tileSize;
return { cx: ww * ts * 0.5, cy: hh * ts * 0.5 };
}
function isQuizBattle() { return mapData && mapData.gameType === 'quiz_battle'; }
function quizCarryNeonColorForChoice(choiceIndex) {
const i = Math.max(0, choiceIndex | 0);
const h = (i * 47 + 100) % 360;
return 'hsl(' + h + ', 72%, 62%)';
}
function quizCarryMinimapOptionFillCss(ov) {
const th = getEffectiveCarryChoicePlaqueThemeForChoice((Number(ov) | 0) - 1);
if (th.borderMode === 'fixed' && th.fixedBorder) {
const m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(String(th.fixedBorder).trim());
if (m) {
const a0 = m[4] != null && m[4] !== '' ? Number(m[4]) : 1;
const aa = (Number.isFinite(a0) ? Math.max(0, Math.min(1, a0)) : 1) * 0.32;
const t = Math.round(aa * 1000) / 1000;
return 'rgba(' + m[1] + ',' + m[2] + ',' + m[3] + ',' + t + ')';
}
}
const i = Math.max(0, ov - 1);
const h = (i * 47 + 100) % 360;
return 'hsla(' + h + ', 58%, 52%, 0.32)';
}
function normalizeQuizCarryQuestionFromAny(q) {
if (!q) return null;
let text = String(q.text || q.question || q.prompt || q.title || '').trim();
if (Array.isArray(q.choices) && q.choices.length >= 2) {
const choices = q.choices.map((c) => String(c));
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci) || ci < 0 || ci >= choices.length) ci = 0;
if (!text) text = 'เลือกคำตอบที่ถูกต้อง — หยิบตัวเลือกไปวางโซนส่งคำตอบ';
let chSlot = Number(q.countdownHighlightSlot);
if (!Number.isFinite(chSlot) || chSlot < 1 || chSlot > 16) chSlot = null;
else chSlot = Math.floor(chSlot);
let choiceImageUrls = null;
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
const urls = choices.map((_, idx) => sanitizeQuizCarryImageUrlClient(q.choiceImageUrls[idx]));
if (urls.some((u) => u)) choiceImageUrls = urls;
}
return { text, choices, correctIndex: ci, countdownHighlightSlot: chSlot, choiceImageUrls };
}
if (!text) return null;
const t = !!q.answerTrue;
return { text, choices: ['จริง', 'เท็จ'], correctIndex: t ? 0 : 1, countdownHighlightSlot: null };
}
function buildQuizCarryPoolFromMap(md) {
if (!md || !Array.isArray(md.quizQuestions)) return [];
const out = [];
for (let i = 0; i < md.quizQuestions.length; i++) {
const n = normalizeQuizCarryQuestionFromAny(md.quizQuestions[i]);
if (n) out.push(n);
}
return out;
}
function getQuizCarryHubTileBounds(md) {
if (!md || md.gameType !== 'quiz_carry') return null;
const grid = md.quizCarryHubArea;
if (!grid || !grid.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let yy = 0; yy < grid.length; yy++) {
const row = grid[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (row[xx] === 1) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
}
}
}
if (minX === Infinity) return null;
return { minX, minY, maxX, maxY };
}
/** จุดกลางโซนตัวเลือกหยิบมาวาง (ค่า grid 1..N = ลำดับ choices) เพื่อวาดข้อความบนแผนที่ */
function getQuizCarryOptionClusterBounds(md, slot1toN) {
const g = md && md.quizCarryOptionArea;
if (!g || !g.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let n = 0, sumX = 0, sumY = 0;
for (let yy = 0; yy < g.length; yy++) {
const row = g[yy];
if (!row) continue;
for (let xx = 0; xx < row.length; xx++) {
if (Number(row[xx]) === slot1toN) {
if (xx < minX) minX = xx;
if (yy < minY) minY = yy;
if (xx > maxX) maxX = xx;
if (yy > maxY) maxY = yy;
sumX += xx + 0.5;
sumY += yy + 0.5;
n++;
}
}
}
if (!n || minX === Infinity) return null;
return {
minX, minY, maxX, maxY,
cx: sumX / n,
cy: sumY / n,
};
}
function canvasWordWrapLines(ctx, text, maxWidth) {
const raw = String(text || '').trim();
if (!raw) return [];
const words = raw.split(/\s+/);
const lines = [];
let cur = words[0];
for (let i = 1; i < words.length; i++) {
const w = words[i];
const test = cur + ' ' + w;
if (ctx.measureText(test).width <= maxWidth) cur = test;
else {
lines.push(cur);
cur = w;
}
}
lines.push(cur);
return lines;
}
function quizCarryOptionHeldByAnyone(optionIdx) {
if (optionIdx == null || optionIdx < 0) return false;
if (me.quizCarryHeld === optionIdx) return true;
for (const o of others.values()) {
if (o && o.quizCarryHeld === optionIdx) return true;
}
return false;
}
/** สไปรต์ทับช่องโซนกลาง (ม่วง) อย่างน้อย 1 ช่อง */
function spriteOverlapsQuizCarryHubArea(md, sp) {
if (!md || md.gameType !== 'quiz_carry' || !sp) return false;
const hub = md.quizCarryHubArea;
if (!hub || !hub.length) return false;
const mw = md.width || 20, mh = md.height || 15;
const x0 = sp.x | 0, y0 = sp.y | 0, ww = sp.w | 0, hh = sp.h | 0;
for (let yy = y0; yy < y0 + hh; yy++) {
if (yy < 0 || yy >= mh) continue;
const row = hub[yy];
if (!row) continue;
for (let xx = x0; xx < x0 + ww; xx++) {
if (xx < 0 || xx >= mw) continue;
if (row[xx] === 1) return true;
}
}
return false;
}
/** ตัวเลือกหนึ่งข้อ — มีคนถือป้ายอยู่ได้แค่คนเดียว (หยิบซ้ำไม่ได้) */
function pickQuizCarryTargetOptionIndexAvoidingTaken(preferredIdx) {
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
if (n < 1) return null;
let p = preferredIdx | 0;
if (p < 0 || p >= n) p = 0;
if (!quizCarryOptionHeldByAnyone(p)) return p;
const avail = [];
for (let i = 0; i < n; i++) {
if (!quizCarryOptionHeldByAnyone(i)) avail.push(i);
}
if (!avail.length) return null;
return avail[Math.floor(Math.random() * avail.length)];
}
/**
* วาดป้ายมุมมน + ขอบเรือง + ข้อความ (รูปแบบเดียวกันทั้งแมปและติดตัว) — สีจาก carryChoicePlaqueThemes[choiceIndex] · รูปจาก plaqueExtra.imageUrl
* @param tether ถ้ามี วาดเส้นขาวจาก (x0,y0) ถึง (x1,y1) ก่อนป้าย
* @param plaqueExtra optional { imageUrl }
*/
function drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, lines, lineH, padY, choiceIndex, tether, plaqueExtra) {
const th = getEffectiveCarryChoicePlaqueThemeForChoice(choiceIndex);
const neon = th.borderMode === 'fixed' ? th.fixedBorder : quizCarryNeonColorForChoice(choiceIndex);
const fillCol = th.fillBg || 'rgba(12, 10, 20, 0.88)';
const textCol = th.textColor || '#f8f9ff';
const baseLw = Math.max(0.5, Math.min(8, Number(th.borderWidthPx)));
const imgUrl = plaqueExtra && plaqueExtra.imageUrl ? String(plaqueExtra.imageUrl) : '';
const elImg = plaqueExtra && plaqueExtra.imageElement;
const imgFromEl = (elImg && elImg.complete && elImg.naturalWidth > 0) ? elImg : null;
const img = !imgFromEl && imgUrl ? getQuizCarryChoiceImageCached(imgUrl) : null;
const drawPlaqueImg = imgFromEl || ((img && img.complete && img.naturalWidth > 0) ? img : null);
const rr = Math.max(6, Math.min(12, Math.min(w, h) * 0.2));
const simplePreviewPlaque = previewMode && editorEmbedReturn;
function pathBoard() {
ctx.beginPath();
if (typeof ctx.roundRect === 'function') ctx.roundRect(signX, signY, w, h, rr);
else ctx.rect(signX, signY, w, h);
}
if (!simplePreviewPlaque) {
for (let g = 4; g >= 1; g--) {
ctx.strokeStyle = neon;
ctx.globalAlpha = 0.07 + g * 0.06;
ctx.lineWidth = baseLw + g * 3.2;
pathBoard();
ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.fillStyle = fillCol;
pathBoard();
ctx.fill();
if (drawPlaqueImg) {
ctx.save();
pathBoard();
ctx.clip();
const padImg = Math.max(2, padY * 0.65);
const ix = signX + padImg;
const iy = signY + padImg;
const iw = Math.max(0, w - padImg * 2);
const ih = Math.max(0, h - padImg * 2);
if (iw > 0 && ih > 0) {
const nw = drawPlaqueImg.naturalWidth;
const nh = drawPlaqueImg.naturalHeight;
const ir = nw / nh;
let dw;
let dh;
if (iw / ih > ir) {
dh = ih;
dw = dh * ir;
} else {
dw = iw;
dh = dw / ir;
}
const dx = ix + (iw - dw) / 2;
const dy = iy + (ih - dh) / 2;
ctx.drawImage(drawPlaqueImg, 0, 0, nw, nh, dx, dy, dw, dh);
}
ctx.restore();
}
ctx.strokeStyle = neon;
if (simplePreviewPlaque) {
ctx.lineWidth = Math.max(1, baseLw * 0.85);
ctx.shadowBlur = 0;
pathBoard();
ctx.stroke();
} else {
ctx.lineWidth = baseLw;
ctx.shadowColor = neon;
ctx.shadowBlur = 14;
pathBoard();
ctx.stroke();
ctx.shadowBlur = 8;
pathBoard();
ctx.stroke();
ctx.shadowBlur = 0;
}
const attachX = signX + w / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = textCol;
const lh = lineH || 12;
if (!simplePreviewPlaque && lines && lines.length) {
ctx.shadowColor = 'rgba(0,0,0,0.55)';
ctx.shadowBlur = 3;
}
if (lines && lines.length) {
const n = lines.length;
const textBlockH = n * lh;
const y0 = signY + Math.max(padY, (h - textBlockH) / 2);
const firstLineCenterY = y0 + lh / 2;
for (let li = 0; li < n; li++) {
ctx.fillText(lines[li], attachX, firstLineCenterY + li * lh);
}
}
ctx.shadowBlur = 0;
if (tether && Number.isFinite(tether.x0) && Number.isFinite(tether.y0)) {
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.moveTo(tether.x0, tether.y0);
ctx.lineTo(tether.x1, tether.y1);
ctx.stroke();
}
}
/**
* กล่องป้ายบนจอ (signX, signY, w, h) ให้ตรงกับ drawQuizCarryChoiceLabels สำหรับช่อง idx 0-based
*/
function computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, idx0Based) {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return null;
const q = quizCarryCurrent;
const choices = q.choices;
if (!choices || !choices.length || idx0Based < 0 || idx0Based >= choices.length) return null;
const i = idx0Based;
const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
if (!b) return null;
const ts = tileSize * zoom;
const ps = quizCarryPlaqueMapScaleClampedPlay();
const choiceText = String(choices[i] || '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(q, i);
const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
: (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) return null;
const tileSpanX = (b.maxX - b.minX + 1);
const tileSpanY = (b.maxY - b.minY + 1);
const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
ctx.save();
let signX;
let signY;
let w;
let h;
if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
const minW = minPlaqueW;
const minH = minPlaqueH;
signX = sx - minW / 2;
signY = sy - minH / 2;
w = minW;
h = minH;
ctx.restore();
return { signX, signY, w, h };
}
const line = choiceText;
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
let maxW = Math.max(56, tileSpanX * ts - 14);
const lineH = fontPx * 1.2;
let textLines = canvasWordWrapLines(ctx, line, maxW);
if (!textLines.length) textLines = [line];
if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
const fp2 = Math.max(9, fontPx * 0.85);
ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
const lh2 = fp2 * 1.2;
maxW = Math.max(48, maxW - 4);
textLines = canvasWordWrapLines(ctx, line, maxW);
if (textLines.length * lh2 > tileSpanY * ts - 8) {
textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
const padX = 10;
const padY = 8;
w = Math.ceil(maxLineW + padX * 2);
h = Math.ceil(textLines.length * lh2 + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
signX = sx - w / 2;
signY = sy - h / 2;
ctx.restore();
return { signX, signY, w, h };
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
let maxLineW = 0;
for (let ti = 0; ti < textLines.length; ti++) {
const lw = ctx.measureText(textLines[ti]).width;
if (lw > maxLineW) maxLineW = lw;
}
const padX = 10;
const padY = 8;
w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
h = Math.ceil(textLines.length * lineH + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
signX = sx - w / 2;
signY = sy - h / 2;
ctx.restore();
return { signX, signY, w, h };
}
/**
* ขอบเรืองรอบช่องตัวเลือก (carryEmbedCountdownHighlight + carryEmbedCountdownHighlightColor)
* ขนาดกรอบเท่าป้ายบนแมป (เดียวกับ drawQuizCarryChoiceLabels) ไม่ใช่ทั้ง cluster กริด
*/
function drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zoom, timeMs) {
if (!isQuizCarry() || !mapData) return;
if (mapData.carryEmbedCountdownHighlight === false) return;
const optIdx = getQuizCarryLocalGrabbableOptionIndex();
if (optIdx == null) return;
const pr = computeQuizCarryChoicePlaqueScreenRect(ctx, worldToScreen, zoom, optIdx);
if (!pr || pr.w < 4 || pr.h < 4) return;
const rx = pr.signX;
const ry = pr.signY;
const rw = pr.w;
const rh = pr.h;
const rr = Math.max(6, Math.min(16, Math.min(rw, rh) * 0.14));
const colRaw = mapData.carryEmbedCountdownHighlightColor;
const strokeBase = typeof colRaw === 'string' && /^#[0-9a-fA-F]{6}$/.test(colRaw.trim()) ? colRaw.trim() : '#ffb44a';
const pulse = 0.55 + 0.45 * Math.sin((timeMs || 0) / 180);
ctx.save();
function pathRr() {
ctx.beginPath();
if (typeof ctx.roundRect === 'function') ctx.roundRect(rx, ry, rw, rh, rr);
else ctx.rect(rx, ry, rw, rh);
}
pathRr();
ctx.fillStyle = strokeBase;
ctx.globalAlpha = 0.12 * pulse;
ctx.fill();
for (let g = 6; g >= 1; g--) {
ctx.strokeStyle = strokeBase;
ctx.globalAlpha = (0.07 + g * 0.055) * pulse;
ctx.lineWidth = 2.5 + g * 4;
pathRr();
ctx.stroke();
}
ctx.globalAlpha = 0.98;
ctx.strokeStyle = strokeBase;
ctx.lineWidth = 4;
ctx.shadowColor = strokeBase;
ctx.shadowBlur = 22 * pulse;
pathRr();
ctx.stroke();
ctx.shadowBlur = 12 * pulse;
pathRr();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.restore();
}
function drawQuizCarryChoiceLabels(ctx, worldToScreen, zoom) {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
if (!quizCarryOptionsPickableNow()) return;
const choices = quizCarryCurrent.choices;
if (!choices || !choices.length) return;
const maxSlots = choices.length;
const ts = tileSize * zoom;
const ps = quizCarryPlaqueMapScaleClampedPlay();
ctx.save();
for (let i = 0; i < Math.min(choices.length, maxSlots); i++) {
if (quizCarryOptionHeldByAnyone(i)) continue;
const b = getQuizCarryOptionClusterBounds(mapData, i + 1);
if (!b) continue;
const choiceText = String(choices[i] || '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, i);
const gridIdleEl = findQuizCarryGridIdleImgForChoiceIndex(i);
const plaqueExtra = imgUrl ? { imageUrl: imgUrl }
: (gridIdleEl && gridIdleEl.complete && gridIdleEl.naturalWidth ? { imageElement: gridIdleEl } : {});
if (!choiceText && !plaqueExtra.imageUrl && !plaqueExtra.imageElement) continue;
const tileSpanX = (b.maxX - b.minX + 1);
const tileSpanY = (b.maxY - b.minY + 1);
const minPlaqueW = Math.ceil(Math.max(72, tileSpanX * ts - 16) * ps);
const minPlaqueH = Math.ceil(Math.max(52, tileSpanY * ts - 12) * ps);
if (!choiceText && (plaqueExtra.imageUrl || plaqueExtra.imageElement)) {
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
const lineH = fontPx * 1.2;
const padY = 8;
const minW = minPlaqueW;
const minH = minPlaqueH;
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
drawQuizCarryNeonPlaque(ctx, sx - minW / 2, sy - minH / 2, minW, minH, [], lineH, padY, i, null, plaqueExtra);
continue;
}
const line = choiceText;
const fontPx = Math.max(10, Math.min(24, ts * 0.24 * ps));
ctx.font = '600 ' + fontPx + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
let maxW = Math.max(56, tileSpanX * ts - 14);
const lineH = fontPx * 1.2;
let textLines = canvasWordWrapLines(ctx, line, maxW);
if (!textLines.length) textLines = [line];
if (textLines.length * lineH > tileSpanY * ts - 8 && textLines.length > 1) {
const fp2 = Math.max(9, fontPx * 0.85);
ctx.font = '600 ' + fp2 + 'px system-ui, "Segoe UI", "Kanit", sans-serif';
const lh2 = fp2 * 1.2;
maxW = Math.max(48, maxW - 4);
textLines = canvasWordWrapLines(ctx, line, maxW);
if (textLines.length * lh2 > tileSpanY * ts - 8) {
textLines = textLines.slice(0, Math.max(1, Math.floor((tileSpanY * ts - 8) / lh2)));
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
const maxLineW = Math.max(...textLines.map((t) => ctx.measureText(t).width), 40);
const padX = 10;
const padY = 8;
let w = Math.ceil(maxLineW + padX * 2);
let h = Math.ceil(textLines.length * lh2 + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lh2, padY, i, null, plaqueExtra);
continue;
}
for (let ti = 0; ti < textLines.length; ti++) {
let s = textLines[ti];
if (ctx.measureText(s).width <= maxW) continue;
while (s.length > 2 && ctx.measureText(s + '…').width > maxW) s = s.slice(0, -1);
textLines[ti] = s + '…';
}
let maxLineW = 0;
for (let ti = 0; ti < textLines.length; ti++) {
const lw = ctx.measureText(textLines[ti]).width;
if (lw > maxLineW) maxLineW = lw;
}
const padX = 10;
const padY = 8;
let w = Math.ceil(Math.max(maxLineW, 52) + padX * 2);
let h = Math.ceil(textLines.length * lineH + padY * 2);
if (imgUrl || plaqueExtra.imageElement) {
w = Math.max(w, minPlaqueW);
h = Math.max(h, minPlaqueH);
}
const wx = b.cx * tileSize;
const wy = b.cy * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const signX = sx - w / 2;
const signY = sy - h / 2;
drawQuizCarryNeonPlaque(ctx, signX, signY, w, h, textLines, lineH, padY, i, null, plaqueExtra);
}
ctx.restore();
}
function quizCarryHubCellPlay(md, tx, ty) {
const g = md && md.quizCarryHubArea;
return !!(g && g[ty] && g[ty][tx] === 1);
}
function quizCarryInteractiveCellPlay(md, tx, ty) {
const g = md && md.interactive;
return !!(g && g[ty] && g[ty][tx] === 1);
}
function quizCarryMapHasAnswerInteractive(md) {
if (!md || !md.interactive || !md.interactive.length) return false;
const h = md.height || 15, w = md.width || 20;
for (let y = 0; y < h; y++) {
const row = md.interactive[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1) return true;
}
}
return false;
}
/** @returns {number|null} index ใน choices จากช่องหมายเลข 1..N บนแมป */
function quizCarryOptionIndexAtPlay(md, tx, ty) {
const g = md && md.quizCarryOptionArea;
if (!g || !g[ty]) return null;
const v = g[ty][tx];
if (v < 1 || v > QUIZ_CARRY_MAX_OPTION_SLOTS) return null;
const idx = v - 1;
const n = quizCarryCurrent && quizCarryCurrent.choices ? quizCarryCurrent.choices.length : 0;
if (n < 1 || idx >= n) return null;
return idx;
}
/** ร่างตัวละครทับช่องโซนกลาง (ม่วง) — ใช้บล็อกการเดิน */
function quizCarryFootprintOverlapsHub(px, py) {
if (!mapData || !isQuizCarry()) return false;
const md = mapData;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryHubCellPlay(md, tx, ty)) return true;
}
return false;
}
/** ยืนข้างนอกแต่ชิดโซนกลาง — ใช้ให้กด F ส่งคำตอบได้โดยไม่ต้องย่ำบนม่วง */
function quizCarryFootprintAdjacentToHub(md, px, py) {
if (!md) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryHubCellPlay(md, tx, ty)) continue;
for (let di = 0; di < dirs.length; di++) {
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
if (quizCarryHubCellPlay(md, nx, ny)) return true;
}
}
return false;
}
function quizCarryCanSubmitAtHub(md, px, py) {
if (!md) return false;
return quizCarryFootprintOverlapsHub(px, py) || quizCarryFootprintAdjacentToHub(md, px, py);
}
function quizCarryFootprintOverlapsInteractive(md, px, py) {
if (!md) return false;
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryInteractiveCellPlay(md, tx, ty)) return true;
}
return false;
}
function quizCarryFootprintAdjacentToInteractive(md, px, py) {
if (!md) return false;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const k of quizTilesFootprintPlay(px, py)) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
if (quizCarryInteractiveCellPlay(md, tx, ty)) continue;
for (let di = 0; di < dirs.length; di++) {
const nx = tx + dirs[di][0], ny = ty + dirs[di][1];
if (quizCarryInteractiveCellPlay(md, nx, ny)) return true;
}
}
return false;
}
function quizCarryCanSubmitAtInteractiveZone(md, px, py) {
if (!md) return false;
return quizCarryFootprintOverlapsInteractive(md, px, py) || quizCarryFootprintAdjacentToInteractive(md, px, py);
}
/** ส่งคำตอบ: ถ้ามีโซน interactive (เขียว) ในแมป ใช้โซนนั้น — ไม่มี fallback ชิดโซนกลาง */
function quizCarryCanSubmitAnswerAt(md, px, py) {
if (!md) return false;
if (quizCarryMapHasAnswerInteractive(md)) {
return quizCarryCanSubmitAtInteractiveZone(md, px, py);
}
return quizCarryCanSubmitAtHub(md, px, py);
}
/** ข้อความใน HUD / โซนคำถาม — ไม่รวมตัวเลือก (แสดงบนแผนที่ตามจุดสี) */
function formatQuizCarryQuestionHud(q) {
if (!q) return '';
return String(q.text || q.question || q.prompt || q.title || '').trim();
}
function quizCarryRestoreEmbedPhaseLabel() {
const phaseEl = document.getElementById('quiz-game-phase-label');
if (!phaseEl || !mapData) return;
phaseEl.textContent = quizCarryMapHasAnswerInteractive(mapData)
? 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งที่โซน interactive (เขียว) — เล่นพร้อมกันได้'
: 'กด F หรือปุ่ม GRAB หยิบบนโซนตัวเลือก · ส่งเมื่อชิดโซนกลาง (ม่วงเป็นกำแพง) — เล่นพร้อมกันได้';
}
function isQuizCarryEmbedCountdownBlockingMovement() {
if (!quizCarryEmbedMissionFlowActive()) return false;
const now = Date.now();
return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now);
}
function isQuizCarryEmbedLobbyBlockingMovement() {
return !!(quizCarryEmbedMissionFlowActive() && quizCarryPregameActive);
}
function quizCarryPregameHumanIds() {
const ids = [];
if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId));
others.forEach((_, id) => {
if (!isPreviewBotId(id)) ids.push(String(id));
});
return ids;
}
function quizCarryPregameBotCount() {
let n = 0;
others.forEach((_, id) => {
if (isPreviewBotId(id)) n++;
});
return n;
}
function quizCarryPregameReadyNumerator() {
let n = quizCarryPregameBotCount();
quizCarryPregameHumanIds().forEach((id) => {
if (quizCarryLobbyReadyMap[id]) n++;
});
return n;
}
function quizCarryPregameTotalPlayers() {
return quizCarryPregameHumanIds().length + quizCarryPregameBotCount();
}
function isMePlayHost() {
if (myId == null) return false;
if (playHostId == null) return true;
return String(playHostId) === String(myId);
}
function hideQuizCarryPregameOverlay() {
const ov = document.getElementById('quiz-carry-pregame-overlay');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
}
function showQuizCarryPregameOverlay() {
const ov = document.getElementById('quiz-carry-pregame-overlay');
if (ov) {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
updateQuizCarryPregameHud();
}
function quizCarrySyncGuestReadyIfNeeded() {
if (!quizCarryPregameActive || myId == null || isMePlayHost()) return;
const sid = String(myId);
if (quizCarryLobbyReadyMap[sid]) return;
quizCarryLobbyReadyMap[sid] = true;
if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true });
}
function updateQuizCarryPregameHud() {
if (!quizCarryPregameActive) return;
const st = document.getElementById('quiz-carry-pregame-status');
const primary = document.getElementById('quiz-carry-pregame-primary');
const primaryImg = document.getElementById('quiz-carry-pregame-primary-img');
const num = quizCarryPregameReadyNumerator();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
if (st) {
st.textContent = 'Ready Status : ' + num + '/' + tot;
}
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]);
const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]);
if (primaryImg) {
primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png';
primaryImg.alt = humansReady ? 'START' : 'READY';
}
if (primary) {
primary.classList.toggle('is-start-phase', humansReady);
primary.classList.toggle('is-read-only', !isMePlayHost());
primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady);
primary.disabled = !isMePlayHost();
primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false'));
primary.title = isMePlayHost()
? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
}
function beginQuizCarryEmbedPregame() {
if (!quizCarryEmbedMissionFlowActive()) return;
quizCarryPregameActive = true;
quizCarryLobbyReadyMap = {};
quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; });
showQuizCarryPregameOverlay();
if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request');
quizCarrySyncGuestReadyIfNeeded();
updateQuizCarryPregameHud();
}
function endQuizCarryEmbedPregameAndStart() {
if (!quizCarryPregameActive) return;
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryPickNextQuestion();
const nowEmb = Date.now();
if (!quizCarryEmbedMissionFlowActive()) {
quizCarryRestoreEmbedPhaseLabel();
} else if (!(quizCarryEmbedCountdownEndAt > nowEmb) && !(quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) {
quizCarryRestoreEmbedPhaseLabel();
}
}
function quizCarryOptionsPickableNow() {
if (!isQuizCarry() || !quizCarryCurrent) return true;
/* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */
if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return false;
if (!quizCarryOptionRevealAt || quizCarryOptionRevealAt <= 0) return true;
return Date.now() >= quizCarryOptionRevealAt;
}
function quizCarryApplyPhaseTimersForCurrentQuestion() {
if (!quizCarryCurrent) {
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
return;
}
const readMs = Math.max(0, Math.floor(Number(quizCarryCarryTimingMs.carryReadMs)) || 0);
const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
const t0 = Date.now();
if (readMs <= 0) {
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = t0 + ansMs;
} else {
quizCarryOptionRevealAt = t0 + readMs;
quizCarryAnswerCloseAt = quizCarryOptionRevealAt + ansMs;
}
}
/** embed: หลัง 3–2–1 ก่อนหยิบ — เปิดป้ายตัวเลือกทันที + จับเวลาตอบ carryAnswerMs */
function quizCarryApplyEmbedAnswerWindowAfterPreOption321() {
if (!quizCarryCurrent) return;
const ansMs = Math.max(1000, Math.floor(Number(quizCarryCarryTimingMs.carryAnswerMs)) || 5000);
const t0 = Date.now();
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = t0 + ansMs;
}
function updateQuizCarryCarryPhaseHud() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!isQuizCarry() || !quizCarryCurrent) return;
if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) return;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (!phaseEl) return;
if (quizCarryAnswerTimeupAwaitNext && quizCarryAnswerRoundTimeupAdvanceAt > 0) {
const sec = Math.max(0, Math.ceil((quizCarryAnswerRoundTimeupAdvanceAt - Date.now()) / 1000));
phaseEl.textContent = 'หมดเวลาตอบ — ไปข้อถัดไปใน ' + sec + ' วิ…';
return;
}
const now = Date.now();
if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > 0 && now < quizCarryEmbedPreOptionCountdownEndAt) {
const sec = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - now) / 1000));
phaseEl.textContent = 'เตรียมหยิบคำตอบ — นับ 3·2·1 (' + sec + ' วิ)';
return;
}
if (quizCarryOptionRevealAt > 0 && now < quizCarryOptionRevealAt) {
const remain = Math.max(0, Math.ceil((quizCarryOptionRevealAt - now) / 1000));
phaseEl.textContent = 'อ่านคำถาม — ตัวเลือกขึ้นใน ' + remain + ' วิ';
return;
}
if (quizCarryAnswerCloseAt > now) {
const remain = Math.max(0, Math.ceil((quizCarryAnswerCloseAt - now) / 1000));
phaseEl.textContent = 'หยิบ/ส่งคำตอบ — เหลือ ' + remain + ' วิ';
return;
}
quizCarryRestoreEmbedPhaseLabel();
}
function tickQuizCarryRoundTimers() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return;
if (quizCarryEmbedCountdownEndAt > Date.now()) return;
if (quizCarryEmbedPreOptionCountdownEndAt > Date.now()) return;
if (quizCarryEmbedPendingQuestion) return;
if (quizCarryAnswerTimeupAwaitNext) return;
if (!quizCarryAnswerCloseAt || quizCarryAnswerCloseAt <= 0) return;
if (Date.now() < quizCarryAnswerCloseAt) return;
quizCarryAnswerCloseAt = 0;
quizCarryOptionRevealAt = 0;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
renderPlayQuizScoreboard(playLiveQuizScores);
if (isDetectiveMinigamePlay()) {
quizCarryAfterRoundResolved();
return;
}
quizCarryAnswerTimeupAwaitNext = true;
showQuizCarryAnswerRoundTimeupOverlay();
updateQuizCarryCarryPhaseHud();
}
function resetQuizCarryEmbedCountdownOverlayInners() {
const cq = document.getElementById('quiz-carry-embed-countdown-q');
const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
if (cq) {
cq.textContent = '';
cq.style.display = 'none';
}
if (ck) {
ck.textContent = 'คำถาม · Question';
ck.style.display = 'none';
ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
}
}
/** นับ 3–2–1: ซ่อนคำถามและหัวข้อ — เหลือแค่รูปเลขจาก /Game/img/QUESTION */
function showQuizCarryEmbedCountdownDigitsOnlyChrome() {
const cq = document.getElementById('quiz-carry-embed-countdown-q');
const ck = document.getElementById('quiz-carry-embed-countdown-kicker');
if (cq) {
cq.textContent = '';
cq.style.display = 'none';
}
if (ck) {
ck.textContent = '';
ck.style.display = 'none';
ck.classList.remove('quiz-carry-embed-countdown-kicker--mission');
}
}
function hideQuizCarryEmbedCountdownOverlay() {
resetQuizCarryEmbedCountdownLayoutDom();
const ov = document.getElementById('quiz-carry-embed-countdown');
if (ov) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
}
resetQuizCarryEmbedCountdownOverlayInners();
}
function tickQuizCarryEmbedCountdown() {
if (!quizCarryEmbedMissionFlowActive()) return;
const now = Date.now();
const ov = document.getElementById('quiz-carry-embed-countdown');
const numEl = document.getElementById('quiz-carry-embed-countdown-num');
if (quizCarryCurrent && quizCarryEmbedPreOptionCountdownEndAt > 0) {
if (!numEl) return;
if (now >= quizCarryEmbedPreOptionCountdownEndAt) {
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryEmbedPreOptionCountdownStartAt = 0;
hideQuizCarryEmbedCountdownOverlay();
quizCarryApplyEmbedAnswerWindowAfterPreOption321();
quizCarryRestoreEmbedPhaseLabel();
syncQuizCarryEmbedQuestionStrip();
updateQuizCarryCarryPhaseHud();
return;
}
const elapsedPre = now - quizCarryEmbedPreOptionCountdownStartAt;
const nPre = 3 - Math.min(2, Math.floor(elapsedPre / 1000));
setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, nPre)));
if (ov) {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
syncQuizCarryEmbedCountdownLayout();
showQuizCarryEmbedCountdownDigitsOnlyChrome();
syncQuizCarryEmbedQuestionStrip();
return;
}
if (!quizCarryEmbedPendingQuestion || quizCarryEmbedCountdownEndAt <= 0) return;
if (now >= quizCarryEmbedCountdownEndAt) {
const pq = quizCarryEmbedPendingQuestion;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
hideQuizCarryEmbedCountdownOverlay();
if (!pq) return;
quizCarryCurrent = pq;
preloadQuizCarryChoiceImages(pq);
playQuizText = formatQuizCarryQuestionHud(pq);
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
quizCarryRestoreEmbedPhaseLabel();
others.forEach((o) => {
if (!o) return;
o.botQuizCarryPathfindAfter = 0;
});
syncQuizCarryEmbedQuestionStrip();
quizCarryEmbedPreOptionCountdownStartAt = Date.now();
quizCarryEmbedPreOptionCountdownEndAt = quizCarryEmbedPreOptionCountdownStartAt + 3000;
updateQuizCarryCarryPhaseHud();
return;
}
const elapsed = now - quizCarryEmbedCountdownStartAt;
const n = 3 - Math.min(2, Math.floor(elapsed / 1000));
setCountdown321QuestionAssetGraphic(numEl, Math.max(1, Math.min(3, n)));
if (ov) {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
syncQuizCarryEmbedCountdownLayout();
showQuizCarryEmbedCountdownDigitsOnlyChrome();
syncQuizCarryEmbedQuestionStrip();
}
function quizCarryPickNextQuestion() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!quizCarryPool.length) {
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
hideQuizCarryEmbedCountdownOverlay();
playQuizText = 'ไม่มีคำถาม — ใส่ quizQuestions ในแมป (choices + correctIndex) หรือตั้งใน Admin';
return;
}
let q;
let guard = 0;
do {
q = quizCarryPool[Math.floor(Math.random() * quizCarryPool.length)];
guard++;
} while (guard < 12 && quizCarryPool.length > 1 && quizCarryCurrent && q && q.text === quizCarryCurrent.text);
if (quizCarryEmbedMissionFlowActive()) {
quizCarryEmbedPendingQuestion = q;
preloadQuizCarryChoiceImages(q);
quizCarryEmbedCountdownStartAt = Date.now();
quizCarryEmbedCountdownEndAt = quizCarryEmbedCountdownStartAt + 3000;
quizCarryEmbedPreOptionCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryCurrent = null;
playQuizText = '';
clearQuizMapQuestionCarryFeedback();
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
o.botPathStuckTicks = 0;
/* ต้องใช้ performance.now() ช่วงกับ stepQuizCarryPreviewBots — ห้ามใช้ Date.now() epoch ไม่งั้นบอทค้าง pathfind ตลอด */
o.botQuizCarryPathfindAfter = 0;
});
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = '…';
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'รอเริ่มคำถาม — นับถอยหลัง 3 · 2 · 1';
tickQuizCarryEmbedCountdown();
syncQuizCarryEmbedQuestionStrip();
return;
}
quizCarryCurrent = q;
preloadQuizCarryChoiceImages(q);
playQuizText = formatQuizCarryQuestionHud(q);
clearQuizMapQuestionCarryFeedback();
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
});
quizCarryApplyPhaseTimersForCurrentQuestion();
}
/** หลังจบข้อหนึ่งข้อ (ถูกหรือหมดเวลา): พรีวิวนับรอบจนครบ carrySessionLength แล้วจบเซสชัน */
function quizCarryAfterRoundResolved() {
if (!isQuizCarry()) return;
if (isDetectiveMinigamePlay() && !quizCarryEmbedMissionFlowActive()) {
finishDetectiveMinigameAndReturnLobby();
return;
}
if (!quizCarryEmbedMissionFlowActive() && !previewMode) {
quizCarryPickNextQuestion();
return;
}
quizCarryRoundsCompleted++;
const lim = quizCarrySessionLength;
if (lim > 0 && quizCarryRoundsCompleted >= lim) {
showQuizCarrySessionCompleteOverlay();
return;
}
quizCarryPickNextQuestion();
}
function playQuizCarryAvatarUrlForMission(characterId) {
if (!characterId) return '';
return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png';
}
/** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */
function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) {
if (!imgEl || !characterId) return;
let attempts = 0;
const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId);
const tick = () => {
attempts++;
const nowT = Date.now();
const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down');
if (!rawImg) {
if (fallbackUrl) imgEl.src = fallbackUrl;
return;
}
if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) {
if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts);
setTimeout(tick, 90);
return;
}
if (!rawImg.naturalWidth) {
if (fallbackUrl) imgEl.src = fallbackUrl;
return;
}
const isMePeer = peerId != null && myId != null && String(peerId) === String(myId);
const lobbySrc = isMePeer ? playLobbyIdleAvatarSrcForCharacter(characterId) : '';
if (lobbySrc) {
imgEl.src = lobbySrc;
return;
}
const tint = resolvePlayTintForPeer(peerId != null ? String(peerId) : String((myId != null && myId !== '') ? myId : 'local'));
const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer);
if (out && out.tagName === 'CANVAS' && out.width > 0) {
try {
imgEl.src = out.toDataURL('image/png');
return;
} catch (e) { /* ต่อไปลอง walk หรือ URL */ }
}
if (out && out.src && out !== rawImg) {
imgEl.src = out.src;
return;
}
if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) {
setTimeout(tick, 120);
return;
}
imgEl.src = rawImg.src || fallbackUrl;
};
tick();
}
/** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */
function quizCarryGradeFromTeamAverage(avgRounded) {
if (avgRounded >= 80) return 'A';
if (avgRounded >= 60) return 'B';
return 'C';
}
function quizCarryRollCardRewardForGrade(grade) {
const u = Math.random() * 100;
if (grade === 'A') {
if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'B') {
if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' };
if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'C') {
if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' };
return { th: 'การ์ดธรรมดา', en: 'Common card' };
}
return null;
}
function quizCarryBuildMissionRankList() {
const ranks = [];
if (myId != null) {
ranks.push({
id: myId,
nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
characterId: me.characterId || getPlayCharacterId(),
});
}
others.forEach((o, id) => {
if (!o) return;
ranks.push({
id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
characterId: o.characterId || null,
});
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
return ranks.slice(0, 5);
}
function cancelEmbedPreviewLobbyReturnTimer() {
if (embedPreviewLobbyReturnTimer != null) {
clearTimeout(embedPreviewLobbyReturnTimer);
embedPreviewLobbyReturnTimer = null;
}
}
function cancelQuizCarryResultEndAfterTimeup() {
if (quizCarryResultEndTimer != null) {
clearTimeout(quizCarryResultEndTimer);
quizCarryResultEndTimer = null;
}
cancelEmbedPreviewLobbyReturnTimer();
}
function cancelQuizCarrySessionCompleteResultToSummary() {
if (quizCarrySessionCompleteResultToSummaryT != null) {
clearTimeout(quizCarrySessionCompleteResultToSummaryT);
quizCarrySessionCompleteResultToSummaryT = null;
}
}
function cancelQuizCarryAnswerRoundTimeupAuto() {
if (quizCarryAnswerRoundTimeupAutoT != null) {
clearTimeout(quizCarryAnswerRoundTimeupAutoT);
quizCarryAnswerRoundTimeupAutoT = null;
}
quizCarryAnswerRoundTimeupAdvanceAt = 0;
}
function quizCarrySetAnswerRoundTimeupVisualActive(on) {
try {
if (on) document.documentElement.classList.add('quiz-carry-answer-timeup-active');
else document.documentElement.classList.remove('quiz-carry-answer-timeup-active');
} catch (e) { /* ignore */ }
}
/** หลังภาพ result-complete / gameover (editor embed + preview): รอ 5s แล้วไปล็อบบี้ห้องพรีวิว */
function scheduleEmbedPreviewReturnToLobbyAfterResultEnd() {
cancelEmbedPreviewLobbyReturnTimer();
if (!(previewMode && editorEmbedReturn)) return;
embedPreviewLobbyReturnTimer = setTimeout(function () {
embedPreviewLobbyReturnTimer = null;
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}, 5000);
}
/** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */
function quizCarryAnyPlayerHasCorrectAnswer() {
if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false;
const keys = Object.keys(playLiveQuizScores);
for (let i = 0; i < keys.length; i++) {
if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true;
}
return false;
}
function hideQuizCarryResultEndLayer() {
cancelEmbedPreviewLobbyReturnTimer();
const el = document.getElementById('quiz-carry-result-end-layer');
if (!el) return;
el.classList.add('is-hidden');
el.setAttribute('aria-hidden', 'true');
}
function showQuizCarryResultEndLayer(useComplete) {
const el = document.getElementById('quiz-carry-result-end-layer');
const img = document.getElementById('quiz-carry-result-end-img');
if (!el || !img) return;
const fname = useComplete ? 'result-complete.png' : 'result-gameover.png';
if (isSpaceShooterMissionUiMapPlay()) {
img.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
};
img.src = violentCrimeAssetUrl(fname) + '?v=' + String(Date.now());
} else {
img.onerror = null;
img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now());
}
el.classList.remove('is-hidden');
el.setAttribute('aria-hidden', 'false');
}
/** Embed editor: หลัง timeup บนโต๊ะ — รอ 5 วิ แล้วโชว์ result-complete / result-gameover (ใช้ร่วม quiz_carry + crown) */
function scheduleEmbedDeskResultAfterDelay(okResolver) {
cancelQuizCarryAnswerRoundTimeupAuto();
cancelQuizCarryResultEndAfterTimeup();
hideQuizCarryResultEndLayer();
const resolve = typeof okResolver === 'function' ? okResolver : function () { return !!okResolver; };
quizCarryResultEndTimer = setTimeout(function () {
quizCarryResultEndTimer = null;
let anyOk = false;
try {
anyOk = !!resolve();
} catch (e) {
anyOk = false;
}
hideQuizCarryTimeupOnDeskLayer();
showQuizCarryResultEndLayer(anyOk);
scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
}, 5000);
}
function hideQuizCarryTimeupOnDeskLayer() {
cancelQuizCarryAnswerRoundTimeupAuto();
cancelQuizCarryResultEndAfterTimeup();
quizCarrySetAnswerRoundTimeupVisualActive(false);
const el = document.getElementById('quiz-carry-timeup-desk-layer');
if (!el) {
quizCarryAnswerTimeupAwaitNext = false;
return;
}
el.classList.remove('qc-timeup-desk--interactive');
quizCarryAnswerTimeupAwaitNext = false;
const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner');
if (anchor) {
anchor.style.left = '';
anchor.style.top = '';
anchor.style.width = '';
anchor.style.height = '';
}
el.classList.add('is-hidden');
el.setAttribute('aria-hidden', 'true');
}
/** หมดเวลาตอบ (carryAnswerMs) — โชว์ TIME'S UP บนโซนคำถาม ~3s แล้วไปข้อถัดไป (ไม่ใช่ flow 5s → result ของ embed จบภารกิจ) */
function showQuizCarryAnswerRoundTimeupOverlay() {
if (!isQuizCarry()) return;
cancelQuizCarryAnswerRoundTimeupAuto();
const el = document.getElementById('quiz-carry-timeup-desk-layer');
if (!el) return;
const img = document.getElementById('quiz-carry-timeup-txt-img');
if (img) {
img.onerror = null;
img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
}
quizCarrySetAnswerRoundTimeupVisualActive(true);
quizCarryAnswerRoundTimeupAdvanceAt = Date.now() + 3000;
el.classList.add('qc-timeup-desk--interactive');
el.classList.remove('is-hidden');
el.setAttribute('aria-hidden', 'false');
try {
syncQuizCarryTimeupDeskLayerPosition();
} catch (e) { /* ignore */ }
quizCarryAnswerRoundTimeupAutoT = setTimeout(function () {
quizCarryAnswerRoundTimeupAutoT = null;
dismissQuizCarryAnswerRoundTimeupAndContinue();
}, 3000);
}
function dismissQuizCarryAnswerRoundTimeupAndContinue() {
if (!quizCarryAnswerTimeupAwaitNext) return;
cancelQuizCarryAnswerRoundTimeupAuto();
quizCarryAnswerTimeupAwaitNext = false;
hideQuizCarryTimeupOnDeskLayer();
quizCarryAfterRoundResolved();
}
/** หลังกดรับหลักฐาน (embed preview) — แสดง timeup กลางแคนวาส (Jumper mnptfts2 = img/Jumper/result-timeup.png) แยกจากป๊อปสรุปคะแนน
* @param {function():boolean} [embedResultOkFn] — ถ้าไม่ส่ง = ใช้คะแนน quiz_carry (คนตอบถูก); ส่งเมื่อ crown เพื่อเลือก complete vs gameover */
function showQuizCarryTimeupOnDeskLayer(embedResultOkFn) {
cancelQuizCarryAnswerRoundTimeupAuto();
quizCarrySetAnswerRoundTimeupVisualActive(false);
const el = document.getElementById('quiz-carry-timeup-desk-layer');
if (!el) return;
// Last Light (Gauntlet Crown): skip TIME'S UP desk graphic; keep embed 5s → result flow.
if (isGauntletCrownHeistMapPlay()) {
if (previewMode && editorEmbedReturn) {
scheduleEmbedDeskResultAfterDelay(
typeof embedResultOkFn === 'function' ? embedResultOkFn : quizCarryAnyPlayerHasCorrectAnswer
);
}
return;
}
const img = document.getElementById('quiz-carry-timeup-txt-img');
if (img) {
if (isJumpSurviveMissionUiMapPlay()) {
img.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
};
img.src = jumperAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
} else if (isSpaceShooterMissionUiMapPlay()) {
img.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
};
img.src = violentCrimeAssetUrl('result-timeup.png') + '?v=' + String(Date.now());
} else {
img.onerror = null;
img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now());
}
}
el.classList.remove('is-hidden');
el.setAttribute('aria-hidden', 'false');
try {
syncQuizCarryTimeupDeskLayerPosition();
} catch (e) { /* ignore */ }
if (previewMode && editorEmbedReturn) {
scheduleEmbedDeskResultAfterDelay(embedResultOkFn || quizCarryAnyPlayerHasCorrectAnswer);
}
}
function showQuizCarryMissionSummaryOverlay(opts) {
opts = opts || {};
const forceF = !!opts.forceGradeF;
const overlay = document.getElementById('quiz-carry-mission-overlay');
if (!overlay) return;
hideQuizCarryTimeupOnDeskLayer();
const ranks = quizCarryBuildMissionRankList();
const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0));
const total = parts.reduce((a, b) => a + b, 0);
const n = ranks.length;
const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0;
const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade);
const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade);
const rowEl = document.getElementById('qc-mission-rank-row');
const totalEl = document.getElementById('qc-mission-total-line');
const gradeEl = document.getElementById('qc-mission-grade');
const cardTextEl = document.getElementById('qc-mission-card-text');
const checkEl = document.getElementById('qc-mission-check');
const successTxt = document.getElementById('qc-mission-success-txt');
const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]'];
if (rowEl) {
rowEl.innerHTML = '';
ranks.forEach((r, idx) => {
const rank = idx + 1;
const cell = document.createElement('div');
cell.className = 'qc-mission-rank-cell';
const tag = document.createElement('div');
tag.className = 'qc-mission-rank-tag';
tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']');
const frame = document.createElement('div');
frame.className = 'qc-mission-rank-frame';
const img = document.createElement('img');
img.className = 'qc-mission-rank-avatar';
img.alt = '';
applyQuizCarryRankAvatarTint(img, r.characterId, r.id);
img.onerror = function () {
this.onerror = null;
this.src = 'data:image/svg+xml,' + encodeURIComponent('');
};
frame.appendChild(img);
const nick = document.createElement('div');
nick.className = 'qc-mission-rank-nick';
nick.textContent = r.nickname;
nick.title = r.nickname;
const sc = document.createElement('div');
sc.className = 'qc-mission-rank-score';
sc.textContent = String(r.score);
cell.appendChild(tag);
cell.appendChild(frame);
cell.appendChild(nick);
cell.appendChild(sc);
rowEl.appendChild(cell);
});
}
if (totalEl) {
totalEl.textContent = '';
if (!n) {
totalEl.textContent = '—';
} else {
const lab = document.createElement('span');
lab.textContent = 'คะแนนรวม ';
const nums = document.createElement('span');
nums.className = 'qc-mission-total-nums';
nums.textContent = '(' + parts.join('+') + ') = ' + total;
const tail = document.createElement('span');
tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100';
totalEl.appendChild(lab);
totalEl.appendChild(nums);
totalEl.appendChild(tail);
}
}
if (gradeEl) {
gradeEl.textContent = grade;
gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase();
}
const okMission = grade !== 'F';
if (checkEl) checkEl.style.display = okMission ? '' : 'none';
if (successTxt) {
successTxt.style.display = okMission ? '' : 'none';
if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ';
}
if (cardTextEl) {
cardTextEl.textContent = '';
if (grade === 'F') {
const pl = document.createElement('div');
pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted';
const t = document.createElement('div');
t.className = 'qc-mission-reward-plaque-title';
t.textContent = 'ไม่ได้รับการ์ด';
const s = document.createElement('div');
s.className = 'qc-mission-reward-plaque-sub';
s.textContent = 'เกรด F';
pl.appendChild(t);
pl.appendChild(s);
cardTextEl.appendChild(pl);
} else if (reward) {
const pl = document.createElement('div');
pl.className = 'qc-mission-reward-plaque';
const t = document.createElement('div');
t.className = 'qc-mission-reward-plaque-title';
t.textContent = reward.th;
const s = document.createElement('div');
s.className = 'qc-mission-reward-plaque-sub';
s.textContent = reward.en;
pl.appendChild(t);
pl.appendChild(s);
cardTextEl.appendChild(pl);
}
}
overlay.classList.remove('is-hidden');
const btn = document.getElementById('btn-quiz-carry-mission-done');
if (btn) {
btn.onclick = function () {
cancelQuizCarrySessionCompleteResultToSummary();
overlay.classList.add('is-hidden');
if (isDetectiveMinigamePlay()) {
finishDetectiveMinigameAndReturnLobby();
} else {
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}
};
}
}
function showQuizCarrySessionCompleteOverlay() {
if (quizCarrySessionEnded) return;
quizCarrySessionEnded = true;
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
hideQuizCarryEmbedCountdownOverlay();
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
playQuizText = 'ครบชุดคำถามแล้ว';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
const phaseEl = document.getElementById('quiz-game-phase-label');
if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ';
const missionOv = document.getElementById('quiz-carry-mission-overlay');
if (missionOv && quizCarryUseMissionSummaryOverlay) {
cancelQuizCarrySessionCompleteResultToSummary();
hideQuizCarryResultEndLayer();
const summaryOpts = { forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated) };
const splashRanks = quizCarryBuildMissionRankList();
const splashParts = splashRanks.map((r) => Math.max(0, Number(r.score) || 0));
const splashTotal = splashParts.reduce((a, b) => a + b, 0);
const splashN = splashRanks.length;
const splashAvg = splashN ? Math.min(100, Math.max(0, Math.round(splashTotal / splashN))) : 0;
const splashGrade = summaryOpts.forceGradeF ? 'F' : quizCarryGradeFromTeamAverage(splashAvg);
const useCompleteSplash = splashGrade !== 'F';
showQuizCarryResultEndLayer(useCompleteSplash);
quizCarrySessionCompleteResultToSummaryT = setTimeout(function () {
quizCarrySessionCompleteResultToSummaryT = null;
hideQuizCarryResultEndLayer();
showQuizCarryMissionSummaryOverlay(summaryOpts);
}, QUIZ_CARRY_SESSION_END_SPLASH_MS);
renderPlayQuizScoreboard(playLiveQuizScores);
return;
}
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) titleEl.textContent = 'เกมจบ · Game over';
msgEl.textContent = 'ครบจำนวนคำถามที่ตั้งไว้ (' + String(quizCarryRoundsCompleted) + ' ข้อ) · Quiz carry session finished';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({
id: myId,
nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
score: Math.max(0, Number(playLiveQuizScores[myId]) || 0),
});
}
others.forEach((o, id) => {
if (!o) return;
ranks.push({
id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
score: Math.max(0, Number(playLiveQuizScores[id]) || 0),
});
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
if (tryFinishDetectiveMinigameAndReturnLobby()) return;
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
renderPlayQuizScoreboard(playLiveQuizScores);
}
/** Toast กลางบน (หยิบ / ถูก / ผิด / คำใบ้) — ปิดการแสดงตามคำขอ UX */
function showQuizCarryToast(msg, ok) {
if (typeof window.__quizCarryToastT === 'number') clearTimeout(window.__quizCarryToastT);
window.__quizCarryToastT = null;
const el = document.getElementById('play-quiz-feedback');
if (!el) return;
el.classList.add('is-hidden');
el.className = 'play-quiz-feedback';
el.textContent = '';
}
/**
* หยิบ/ส่งคำตอบ — ต้องกด F (fromKey) สำหรับผู้เล่น · บอท preview เรียกแบบ silent เมื่อหยุดเดินแล้ว
* @returns {boolean} ทำ action สำเร็จหรือไม่
*/
function tryQuizCarryInteractionForPlayer(actorId, ox, oy, opts) {
opts = opts || {};
if (quizCarryPregameActive) return false;
if (quizCarrySessionEnded) return false;
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
const md = mapData;
const footprint = quizTilesFootprintPlay(ox, oy);
const ent = actorId === myId ? me : others.get(actorId);
if (!ent) return false;
let pickupIdx = null;
for (const k of footprint) {
const p = k.split(',');
const tx = +p[0], ty = +p[1];
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
}
const canSubmitAnswer = quizCarryCanSubmitAnswerAt(md, ox, oy);
const correct = quizCarryCurrent.correctIndex;
let held = ent.quizCarryHeld;
if (held != null && canSubmitAnswer) {
if (!quizCarryOptionsPickableNow()) return false;
ent.quizCarryHeld = null;
const ok = held === correct;
if (ok) {
playLiveQuizScores[actorId] = (playLiveQuizScores[actorId] || 0) + QUIZ_CARRY_POINTS_PER_CORRECT;
const lim = previewMode && isQuizCarry() ? quizCarrySessionLength : 0;
const willEnd = lim > 0 && (quizCarryRoundsCompleted + 1) >= lim;
const showMapFb = !opts.silent && actorId === myId;
if (showMapFb) showQuizMapQuestionCarryFeedback(true);
if (!opts.silent) {
const pts = QUIZ_CARRY_POINTS_PER_CORRECT;
showQuizCarryToast(willEnd ? ('ถูกต้อง +' + pts + ' แต้ม — ครบชุดคำถาม') : ('ถูกต้อง +' + pts + ' แต้ม — สุ่มคำถามใหม่'), true);
}
me.quizCarryHeld = null;
others.forEach((o) => { if (o) o.quizCarryHeld = null; });
const afterOk = () => { quizCarryAfterRoundResolved(); };
if (showMapFb) {
window.setTimeout(afterOk, 560);
} else {
afterOk();
}
} else {
if (!opts.silent && actorId === myId) {
showQuizMapQuestionCarryFeedback(false);
}
if (!opts.silent) {
showQuizCarryToast('ผิด — คืนป้ายที่โซนตัวเลือกแล้ว กด F หยิบใหม่ได้', false);
}
}
renderPlayQuizScoreboard(playLiveQuizScores);
return true;
}
if (held == null && pickupIdx != null) {
if (!quizCarryOptionsPickableNow()) return false;
if (quizCarryOptionHeldByAnyone(pickupIdx)) {
if (opts.fromKey && actorId === myId && !opts.silent) {
showQuizCarryToast('ตัวเลือกนี้มีคนถืออยู่แล้ว — เลือกข้ออื่น', false);
}
return false;
}
ent.quizCarryHeld = pickupIdx;
const label = (quizCarryCurrent.choices && quizCarryCurrent.choices[pickupIdx]) || String(pickupIdx);
if (!opts.silent) {
showQuizCarryToast('หยิบ: ' + label, true);
}
return true;
}
if (opts.fromKey && actorId === myId && !opts.silent) {
const t = Date.now();
if (t - (me.quizCarryHintT || 0) > 2200) {
me.quizCarryHintT = t;
if (held != null && !canSubmitAnswer) {
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
? 'ถือป้ายอยู่ — ยืนที่โซน interactive (เขียว) หรือชิดขอบแล้วกด F เพื่อส่ง'
: 'ถือป้ายอยู่ — ยืนชิดโซนกลาง (ม่วง) แล้วกด F เพื่อส่ง', false);
} else {
showQuizCarryToast(quizCarryMapHasAnswerInteractive(md)
? 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนโซนเขียว (interactive) แล้วกด F ส่งเมื่อถือป้าย'
: 'ยืนโซนตัวเลือกแล้วกด F หยิบ · ยืนชิดโซนกลางแล้วกด F ส่งเมื่อถือป้าย', false);
}
}
}
return false;
}
function quizCarryGrabCoreGatesOk() {
if (!isQuizCarry() || !mapData || !quizCarryCurrent) return false;
if (quizCarryPregameActive || quizCarrySessionEnded) return false;
if (myId == null) return false;
if (isQuizCarryEmbedCountdownBlockingMovement()) return false;
if (!quizCarryOptionsPickableNow()) return false;
return true;
}
/** ดัชนีตัวเลือก 0-based ที่ผู้เล่นท้องถิ่นยืนอยู่และหยิบได้ — ไม่มีคืน null */
function getQuizCarryLocalGrabbableOptionIndex() {
if (!quizCarryGrabCoreGatesOk()) return null;
if (me.quizCarryHeld != null) return null;
const md = mapData;
const footprint = quizTilesFootprintPlay(me.x, me.y);
let pickupIdx = null;
for (const k of footprint) {
const p = k.split(',');
const tx = +p[0];
const ty = +p[1];
const opt = quizCarryOptionIndexAtPlay(md, tx, ty);
if (opt != null) pickupIdx = pickupIdx === null ? opt : pickupIdx;
}
if (pickupIdx == null || quizCarryOptionHeldByAnyone(pickupIdx)) return null;
return pickupIdx;
}
/** ยืนบนช่องตัวเลือกและหยิบได้ */
function quizCarryGrabPickupAvailable() {
return getQuizCarryLocalGrabbableOptionIndex() != null;
}
/** ถือป้ายแล้วยืนโซนส่งได้ */
function quizCarryGrabSubmitAvailable() {
if (!quizCarryGrabCoreGatesOk()) return false;
if (me.quizCarryHeld == null) return false;
return quizCarryCanSubmitAnswerAt(mapData, me.x, me.y);
}
/** true = กด F / ปุ่ม จะหยิบหรือส่งได้ทันที */
function quizCarryGrabInteractionAvailable() {
return quizCarryGrabPickupAvailable() || quizCarryGrabSubmitAvailable();
}
function syncQuizCarryGrabButton() {
const btn = document.getElementById('quiz-carry-grab-btn');
if (!btn) return;
if (!isQuizCarry() || !mapData) {
btn.classList.add('is-hidden');
btn.classList.remove('quiz-carry-grab-btn--active');
btn.classList.remove('quiz-carry-grab-btn--place');
btn.setAttribute('aria-disabled', 'true');
return;
}
const grabSrc = BASE + '/img/quiz-carry/btn-grab.png';
const placeSrc = BASE + '/img/quiz-carry/btn-drop.png';
const img = btn.querySelector('img');
btn.classList.remove('is-hidden');
const active = quizCarryGrabInteractionAvailable();
btn.classList.toggle('quiz-carry-grab-btn--active', active);
btn.setAttribute('aria-disabled', active ? 'false' : 'true');
btn.style.pointerEvents = active ? '' : 'none';
if (img) {
if (active && quizCarryGrabSubmitAvailable()) {
img.src = placeSrc;
btn.classList.add('quiz-carry-grab-btn--place');
btn.title = 'ส่ง / วางคำตอบ — เหมือนกด F';
btn.setAttribute('aria-label', 'ส่งหรือวางคำตอบ');
} else {
img.src = grabSrc;
btn.classList.remove('quiz-carry-grab-btn--place');
btn.title = 'หยิบตัวเลือก — เหมือนกด F';
btn.setAttribute('aria-label', 'หยิบตัวเลือก (Grab)');
}
}
}
/** Gauntlet (รวมพรมแดง): กระโดด — ใช้ร่วมกับ Space/W/↑ และปุ่ม UI */
function tryRequestGauntletJumpPlay() {
if (!mapData || !isGauntlet() || isChatFocused()) return false;
if (isGauntletCrownPregameBlockingPlay()) return false;
if (isGauntletCrownHeistMapPlay() && me.gauntletEliminated) return false;
const now = Date.now();
if (now - lastGauntletJumpKey < 200) return false;
lastGauntletJumpKey = now;
socket.emit('gauntlet-jump');
return true;
}
function syncGauntletCrownJumpButton() {
const btn = document.getElementById('gauntlet-crown-jump-btn');
if (!btn) return;
const show = !!(mapData && isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay());
if (!show) {
btn.classList.add('is-hidden');
btn.setAttribute('aria-hidden', 'true');
btn.style.pointerEvents = 'none';
return;
}
btn.classList.remove('is-hidden');
btn.setAttribute('aria-hidden', 'false');
btn.style.pointerEvents = '';
}
function syncStackTowerDropButtonPlay() {
const btn = document.getElementById('stack-tower-drop-btn');
if (!btn) return;
if (!mapData || !isStack() || !stackMini) {
btn.classList.add('is-hidden');
btn.setAttribute('aria-hidden', 'true');
btn.style.pointerEvents = 'none';
btn.classList.remove('stack-tower-drop-btn--active');
return;
}
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') {
btn.classList.add('is-hidden');
btn.setAttribute('aria-hidden', 'true');
btn.style.pointerEvents = 'none';
btn.classList.remove('stack-tower-drop-btn--active');
return;
}
btn.classList.remove('is-hidden');
btn.setAttribute('aria-hidden', 'false');
const ok = stackHumanDropGateOkPlay();
btn.classList.toggle('stack-tower-drop-btn--active', ok);
btn.style.pointerEvents = ok ? '' : 'none';
}
function resetQuizCarryPlayState() {
cancelQuizCarrySessionCompleteResultToSummary();
const qcmo = document.getElementById('quiz-carry-mission-overlay');
if (qcmo) qcmo.classList.add('is-hidden');
quizCarryPregameActive = false;
hideQuizCarryPregameOverlay();
quizCarryPool = [];
quizCarryCurrent = null;
quizCarryEmbedPendingQuestion = null;
quizCarryEmbedCountdownStartAt = 0;
quizCarryEmbedCountdownEndAt = 0;
quizCarryEmbedPreOptionCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryOptionRevealAt = 0;
quizCarryAnswerCloseAt = 0;
quizCarryAnswerTimeupAwaitNext = false;
quizCarryRoundsCompleted = 0;
quizCarrySessionEnded = false;
hideQuizCarryEmbedCountdownOverlay();
hideQuizCarryTimeupOnDeskLayer();
hideQuizCarryResultEndLayer();
me.quizCarryHeld = null;
others.forEach((o) => {
if (!o) return;
o.quizCarryHeld = null;
o.botPath = [];
});
}
/** รวม carry จากดิสก์ — ใช้เฉพาะ GET ที่ Node รองรับ (path เดียวกับ server.js) ไม่เรียก .php เพื่อกัน 404 บน nginx/php-fpm */
async function mergeQuizCarrySettingsFromDisk(s) {
const out = s && typeof s === 'object' ? s : {};
try {
const rd = await fetch(BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), { cache: 'no-store' });
if (rd.ok) {
const disk = await rd.json();
if (disk && typeof disk === 'object') {
if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') {
out.carryMapPanelTheme = disk.carryMapPanelTheme;
}
if (disk.quizMapPanelTheme && typeof disk.quizMapPanelTheme === 'object') {
out.quizMapPanelTheme = disk.quizMapPanelTheme;
}
if (disk.carryEmbedCountdownTheme && typeof disk.carryEmbedCountdownTheme === 'object') {
out.carryEmbedCountdownTheme = disk.carryEmbedCountdownTheme;
}
if (Array.isArray(disk.carryChoicePlaqueThemes) && disk.carryChoicePlaqueThemes.length) {
out.carryChoicePlaqueThemes = disk.carryChoicePlaqueThemes;
} else if (disk.carryChoicePlaqueTheme && typeof disk.carryChoicePlaqueTheme === 'object') {
out.carryChoicePlaqueTheme = disk.carryChoicePlaqueTheme;
}
if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs;
if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs;
if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength;
if (disk.carryWalkSpeedMultForMapId != null) {
out.carryWalkSpeedMultForMapId = String(disk.carryWalkSpeedMultForMapId).trim();
}
if (disk.carryWalkSpeedMult != null) {
const wm = Number(disk.carryWalkSpeedMult);
if (Number.isFinite(wm)) out.carryWalkSpeedMult = wm;
}
const diskPlaqueScale = Number(disk.carryChoicePlaqueMapScale);
if (Number.isFinite(diskPlaqueScale)) {
out.carryChoicePlaqueMapScale = Math.max(0.85, Math.min(2.5, diskPlaqueScale));
}
if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) {
out.carryQuestions = disk.carryQuestions;
}
}
}
} catch (e) { /* ignore */ }
return out;
}
async function loadQuizCarryPoolAndStart() {
cancelQuizCarrySessionCompleteResultToSummary();
quizCarryEmbedPreOptionCountdownStartAt = 0;
quizCarryEmbedPreOptionCountdownEndAt = 0;
quizCarryRoundsCompleted = 0;
quizCarrySessionEnded = false;
hideQuizCarryTimeupOnDeskLayer();
hideQuizCarryResultEndLayer();
quizCarrySessionLength = 0;
let pool = [];
let s = {};
const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null;
try {
const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' });
if (r.ok) {
const raw = await r.text();
const trimmed = (raw || '').trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') s = parsed;
}
}
} catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ }
try {
s = await mergeQuizCarrySettingsFromDisk(s);
} catch (e) { /* ignore */ }
if (snap) {
if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs;
if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs;
if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength;
}
if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') {
s.carryMapPanelTheme = snap.carryMapPanelTheme;
}
if (snap && snap.carryEmbedCountdownTheme && typeof snap.carryEmbedCountdownTheme === 'object') {
s.carryEmbedCountdownTheme = snap.carryEmbedCountdownTheme;
}
/* ไม่ทับ carryChoicePlaqueThemes จาก snap ถ้ามีแล้ว — snapshot ตอน join อาจเก่ากว่าไฟล์ที่เพิ่งบันทึกใน Admin (รูปป้ายหาย) */
if (snap && (!Array.isArray(s.carryChoicePlaqueThemes) || !s.carryChoicePlaqueThemes.length)) {
if (Array.isArray(snap.carryChoicePlaqueThemes) && snap.carryChoicePlaqueThemes.length) {
s.carryChoicePlaqueThemes = snap.carryChoicePlaqueThemes;
} else if (snap.carryChoicePlaqueTheme && typeof snap.carryChoicePlaqueTheme === 'object') {
s.carryChoicePlaqueTheme = snap.carryChoicePlaqueTheme;
}
}
if (snap && (s.carryChoicePlaqueMapScale == null || !Number.isFinite(Number(s.carryChoicePlaqueMapScale)))) {
const sm = Number(snap.carryChoicePlaqueMapScale);
if (Number.isFinite(sm)) s.carryChoicePlaqueMapScale = sm;
}
if (snap) {
if ((s.carryWalkSpeedMultForMapId == null || String(s.carryWalkSpeedMultForMapId).trim() === '') && snap.carryWalkSpeedMultForMapId != null) {
s.carryWalkSpeedMultForMapId = snap.carryWalkSpeedMultForMapId;
}
if (s.carryWalkSpeedMult == null && snap.carryWalkSpeedMult != null) s.carryWalkSpeedMult = snap.carryWalkSpeedMult;
}
applyQuizCarryWalkSpeedFromSettingsObj(s);
{
const sc = Number(s.carryChoicePlaqueMapScale);
quizCarryPlaqueMapScale = Number.isFinite(sc) ? Math.max(0.85, Math.min(2.5, sc)) : 1.25;
}
try {
quizCarryCarryTimingMs = {
carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000),
carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000),
};
quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0);
setQuizCarryMapPanelThemeFromApi(s);
setQuizCarryEmbedCountdownThemeFromApi(s);
setQuizCarryChoicePlaqueThemeFromApi(s);
for (let ti = 0; ti < QUIZ_CARRY_MAX_OPTION_SLOTS; ti++) {
const tUrl = sanitizeQuizCarryImageUrlClient(getEffectiveCarryChoicePlaqueThemeForChoice(ti).plaqueImageUrl);
if (!tUrl || quizCarryChoiceImageCache.has(tUrl)) continue;
const pim = new Image();
quizCarryApplyImageCrossOrigin(pim, tUrl);
pim.onload = () => { try { draw(); } catch (e) { /* ignore */ } };
quizCarryChoiceImageCache.set(tUrl, pim);
try {
pim.src = tUrl;
} catch (e) { /* ignore */ }
}
const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0;
const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []);
for (let i = 0; i < source.length; i++) {
const n = normalizeQuizCarryQuestionFromAny(source[i]);
if (n) pool.push(n);
}
} catch (e) { /* map only */ }
if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData);
quizCarryPool = pool;
for (let pi = 0; pi < pool.length; pi++) preloadQuizCarryChoiceImages(pool[pi]);
playLiveQuizScores = {};
if (myId != null) playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
if (!pool.length) {
playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)';
const qEl = document.getElementById('quiz-game-question');
if (qEl) qEl.textContent = playQuizText;
try {
syncPlayQuizMapPanel();
} catch (e) { /* ignore */ }
quizCarryJoinSettingsSnap = null;
return;
}
if (quizCarryEmbedMissionFlowActive()) {
beginQuizCarryEmbedPregame();
} else {
quizCarryPickNextQuestion();
}
const nowJoin = Date.now();
if (!quizCarryEmbedMissionFlowActive()) {
quizCarryRestoreEmbedPhaseLabel();
} else if (!(quizCarryEmbedCountdownEndAt > nowJoin) && !(quizCarryEmbedPreOptionCountdownEndAt > nowJoin)) {
quizCarryRestoreEmbedPhaseLabel();
}
try {
syncPlayQuizMapPanel();
} catch (e) { /* ignore */ }
quizCarryJoinSettingsSnap = null;
}
function setupPlayQuizCarryUi() {
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) {
if (isDetectiveMinigamePlay() || (previewMode && editorEmbedReturn)) {
ov.classList.add('is-hidden');
ov.setAttribute('aria-hidden', 'true');
} else {
ov.classList.remove('is-hidden');
ov.setAttribute('aria-hidden', 'false');
}
}
const tEl = document.getElementById('quiz-game-timer');
if (tEl) tEl.textContent = '';
const leg = document.getElementById('quiz-play-legend');
if (leg) {
leg.textContent = isDetectiveMinigamePlay()
? ''
: (quizCarryMapHasAnswerInteractive(mapData)
? 'แต่ละตัวเลือกมีคนถือได้คนเดียว — ส่งคำตอบที่โซน interactive (เขียว) · โซนกลางม่วงเป็นกำแพง · กด F หรือปุ่ม GRAB มุมขวาล่าง'
: 'แต่ละตัวเลือกมีคนถือได้คนเดียว — โซนกลางเป็นกำแพง · ยืนชิดขอบแล้วกด F หรือปุ่ม GRAB ส่งเมื่อถือป้าย');
}
resetQuizCarryPlayState();
if (previewMode) {
loadQuizCarryPoolAndStart();
} else {
playQuizText = isDetectiveMinigamePlay() ? '' : 'เข้าห้องแล้ว — คำถามจากแมป (เล่นจริงแนะนำซิงก์ผ่านโฮสต์ในอนาคต)';
loadQuizCarryPoolAndStart();
}
renderPlayQuizScoreboard(playLiveQuizScores);
syncQuizCarryGrabButton();
}
function pickRandomTileForQuizCarryOption(md, optionIndex, o) {
const w = md.width || 20, h = md.height || 15;
const want = optionIndex + 1;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.quizCarryOptionArea && md.quizCarryOptionArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === want && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileInQuizCarryHub(md, o) {
const w = md.width || 20, h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.quizCarryHubArea && md.quizCarryHubArea[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileInQuizCarryInteractive(md, o) {
const w = md.width || 20, h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = md.interactive && md.interactive[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1 && spawnTileWalkablePlay(md, x, y)) {
if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, o.x, o.y, o)) pool.push({ x, y });
}
}
}
if (!pool.length) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
function pickRandomTileForQuizCarrySubmit(md, o) {
if (quizCarryMapHasAnswerInteractive(md)) {
const p = pickRandomTileInQuizCarryInteractive(md, o);
if (p) return p;
}
return pickRandomTileInQuizCarryHub(md, o);
}
/** ช่วงไม่มีข้อ / รอตัวเลือก — ให้บอทเดินเล่นในแมปเหมือน preview ทั่วไป (กันยืนนิ่งทั้งเกม) */
function quizCarryPreviewBotLobbyWanderStep(o, w, h) {
const now = Date.now();
o.botIsWalking = false;
if (o.botWanderDx == null || o.botWanderDy == null || (o.botWanderDx === 0 && o.botWanderDy === 0)) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
if (typeof o.botWanderNextTurn !== 'number') o.botWanderNextTurn = now + 600;
if (now >= o.botWanderNextTurn) {
o.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200);
if (Math.random() < 0.55) {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
}
}
const accX = o.botWanderDx;
const accY = o.botWanderDy;
if (Math.abs(accY) > Math.abs(accX)) o.direction = accY > 0 ? 'down' : 'up';
else if (accX !== 0) o.direction = accX > 0 ? 'right' : 'left';
const step = MOVE_SPEED * quizCarryWalkSpeedMultActive;
const nx = o.x + accX * step;
const ny = o.y + accY * step;
const ox = o.x;
const oy = o.y;
if (canWalkLikeLobbyForBot(nx, ny, o.x, o.y, o)) {
o.x = nx;
o.y = ny;
} else {
if (canWalkLikeLobbyForBot(nx, o.y, o.x, o.y, o)) {
o.x = nx;
} else if (canWalkLikeLobbyForBot(o.x, ny, o.x, o.y, o)) {
o.y = ny;
} else {
const d = pickRandomPreviewBotWanderDir();
o.botWanderDx = d[0];
o.botWanderDy = d[1];
o.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600);
}
}
if (!Number.isFinite(o.x)) o.x = 0.5;
if (!Number.isFinite(o.y)) o.y = 0.5;
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true;
}
function stepQuizCarryPreviewBots() {
if (quizCarrySessionEnded) return;
if (quizCarryPregameActive) return;
if (!playBotsEnabled() || !isQuizCarry() || !mapData) return;
const w = mapData.width || 20, h = mapData.height || 15;
const nowPf = performance.now();
const pathfindMinGap = editorEmbedReturn ? 280 : 140;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
if (quizCarryFootprintOverlapsHub(o.x, o.y)) {
const sn = snapPositionOutOfQuizCarryHubIfNeeded(o.x, o.y);
if (Math.abs(sn.x - o.x) > 0.02 || Math.abs(sn.y - o.y) > 0.02) {
o.x = sn.x;
o.y = sn.y;
o.botPath = [];
o.botPathStuckTicks = 0;
o.botQuizCarryPathfindAfter = 0;
}
}
if (!o.botPath || o.botPath.length === 0) {
tryQuizCarryInteractionForPlayer(id, o.x, o.y, { silent: true });
}
/* ช่วงนับถอยหลัง / เปลี่ยนข้อ — ยังให้เดินได้ */
if (!quizCarryCurrent) {
quizCarryPreviewBotLobbyWanderStep(o, w, h);
return;
}
const tier = o.botTier || 'avg';
const pCorrect = tier === 'sharp' ? 0.9 : tier === 'weak' ? 0.35 : 0.65;
const rawWant = Math.random() < pCorrect ? quizCarryCurrent.correctIndex
: Math.floor(Math.random() * Math.max(2, (quizCarryCurrent.choices || []).length));
const wantIdx = pickQuizCarryTargetOptionIndexAvoidingTaken(rawWant);
if (!o.botPath || !o.botPath.length) {
if (typeof o.botQuizCarryPathfindAfter !== 'number') o.botQuizCarryPathfindAfter = 0;
if (nowPf < o.botQuizCarryPathfindAfter) {
quizCarryPreviewBotLobbyWanderStep(o, w, h);
return;
}
const jitter = (String(id).length * 31 + ((o.x | 0) ^ (o.y | 0)) * 7) % 160;
if (quizCarryOptionsPickableNow()) {
if (o.quizCarryHeld == null && wantIdx != null) {
const dest = pickRandomTileForQuizCarryOption(mapData, wantIdx, o);
if (dest) {
const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o);
if (path && path.length > 1) o.botPath = path.slice(1);
}
} else {
const sub = pickRandomTileForQuizCarrySubmit(mapData, o);
if (sub) {
const path = pathfindPlayForBot(o.x, o.y, sub.x + 0.5, sub.y + 0.5, o);
if (path && path.length > 1) o.botPath = path.slice(1);
}
}
o.botQuizCarryPathfindAfter = nowPf + pathfindMinGap + jitter;
} else {
o.botQuizCarryPathfindAfter = nowPf + 120;
quizCarryPreviewBotLobbyWanderStep(o, w, h);
}
} else {
stepPreviewBotAlongPath(o, w, h);
}
clampPlayEntityFootprintToMap(o, mapData);
});
separateClumpedPreviewBots();
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const ob = others.get(bid);
if (ob) clampPlayEntityFootprintToMap(ob, mapData);
});
}
function getStackAreaBoundsPlay(area, w, h) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let any = false;
for (let y = 0; y < h; y++) {
const row = area && area[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (row[x] === 1) {
any = true;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
}
if (!any) return null;
return {
minX, maxX, minY, maxY,
cx: (minX + maxX + 1) / 2,
cy: (minY + maxY + 1) / 2,
};
}
/** ปิดการตามความสูงหอจาก JSON เสมอ — เซิร์ฟเคยบังคับ enabled:true ใน policy ทำให้ boost เลื่อนโลก */
function getStackTowerCameraFollowPlayConfig() {
return { enabled: false, pxPerLayer: 12, maxPx: 260 };
}
/** ยกจุดยึดเชือกขึ้นเมื่อหอสูง (y ลดลง = สูงขึ้นในโลก) — เฉพาะ mnn93hpi + เปิดกล้องตามหอ */
function getStackTowerSwingLiftWorldPx(layerCount, layerWorldHPx) {
if (!isStackTowerMissionUiMapPlay() || !getStackTowerCameraFollowPlayConfig().enabled) return 0;
const n = Math.floor(Number(layerCount) || 0);
if (n < STACK_TOWER_SWING_LIFT_FROM_LAYER) return 0;
const lh = Math.max(10, Number(layerWorldHPx) || Math.max(14, (tileSize || 32) * 0.3));
const tiers = n - (STACK_TOWER_SWING_LIFT_FROM_LAYER - 1);
return Math.min(lh * 20, tiers * lh * 0.98);
}
/**
* progress ≥เกณฑ์ Tower + ชั้นพอ: ไม่วาดชั้นล่าง (ข้าม draw) — ตำแหน่ง Y ยังใช้ชั้นจริง (floorY-(i+1)*h)
* เพื่อไม่ให้กองลอยหลุดจากฐานแมปเมื่อเทียบกริด
*/
function getStackTowerVisualHiddenLayerCountPlay() {
if (!isStackTowerMissionHudActivePlay() || !stackMini) return 0;
const p = Number(stackMini.progressPct) || 0;
if (p < STACK_TOWER_POST50_PROGRESS_THRESH) return 0;
const n = stackMini.layers ? stackMini.layers.length : 0;
if (n < 4) return 0;
return Math.floor(n / 2);
}
/**
* world px ตามความสูงหอจริง (mnn93hpi) — คูณ z แล้วได้พิกเซลจอสำหรับเลื่อนแถบ BG
* ใช้ n × layerWorldH (ความสูงชั้นตามบล็อก) คล้มด้วย maxPx จากแมป
*/
function getStackTowerBgScrollHeightBoostPx() {
if (!isStackTowerMissionUiMapPlay() || !stackMini) return 0;
const cfg = getStackTowerCameraFollowPlayConfig();
if (!cfg.enabled || cfg.pxPerLayer <= 0) return 0;
const lh = Math.max(10, Number(stackMini.layerWorldH) || Math.max(14, (tileSize || 32) * 0.31));
const n = stackMini.layers ? stackMini.layers.length : 0;
let layerCount = n;
if (stackFall) {
const dur = Math.max(1, stackFall.dur || 400);
const u = Math.min(1, Math.max(0, (performance.now() - stackFall.t0) / dur));
layerCount = n + u * 0.9;
}
return Math.min(cfg.maxPx, layerCount * lh);
}
/** กล้อง stack: จุดกลางจาก land / release ของแมป (Tower ไม่เลื่อน cy ตามกอง) */
function getStackCameraCentersPx() {
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
let cx = (w / 2) * ts;
let cy = (h / 2) * ts;
if (land && rel) {
cx = land.cx * ts;
cy = ((land.cy + rel.cy) / 2) * ts;
} else if (land) {
cx = land.cx * ts;
cy = land.cy * ts;
} else if (rel) {
cx = rel.cx * ts;
cy = rel.cy * ts;
}
return { px: cx, py: cy };
}
/** ใช้ playStackBlockWidthTiles + แผนที่ — เรียกหลังโหลด /api/game-timing เมื่อหอว่าง */
function reapplyStackMiniSizingFromGlobals() {
if (!mapData || !isStack() || !stackMini) return;
if (stackMini.layers && stackMini.layers.length > 0) return;
if (stackFall || stackMini.settling) return;
const wMap = mapData.width || 20;
const hMap = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
const iw = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
: autoW;
stackMini.initialWidthTiles = iw;
stackMini.widthTiles = iw;
markStackNextDropVisualDirty();
}
function resetStackMinigameState() {
stackMini = null;
stackTowerPost50AnimStartMs = null;
stackTowerHeartMinusFx = null;
if (!mapData || !isStack()) return;
const w = mapData.width || 20, h = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const rel = getStackAreaBoundsPlay(mapData.stackReleaseArea, w, h);
let towerCX = w * 0.5;
let swingAmp = 1.85;
let floorWorldY = (h - 1) * tileSize;
let swingWorldY = floorWorldY - tileSize * 1.35;
if (land) {
towerCX = land.cx;
floorWorldY = (land.maxY + 1) * tileSize;
const span = Math.max(1, land.maxX - land.minX + 1);
swingAmp = Math.min(span * 0.44, Math.max(0.6, span * 0.36));
}
if (rel) {
swingWorldY = (rel.cy + 0.5) * tileSize;
const spanR = Math.max(1, rel.maxX - rel.minX + 1);
swingAmp = Math.max(swingAmp, Math.min(spanR * 0.42, 4.2));
}
const autoW = Math.min(3.2, Math.max(0.85, land ? (land.maxX - land.minX + 1) * 0.48 : 2));
const initW = playStackBlockWidthTiles != null && Number.isFinite(playStackBlockWidthTiles)
? Math.max(0.85, Math.min(3.2, playStackBlockWidthTiles))
: autoW;
stackFall = null;
const towerMission = isStackTowerMissionUiMapPlay();
const towerMul = towerMission ? STACK_TOWER_BLOCK_WORLD_SCALE : 1;
const towerLayerStripH = Math.max(14, tileSize * 0.31) * towerMul;
const missMax = Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)));
stackMini = {
topCenterX: towerCX,
widthTiles: initW,
initialWidthTiles: initW,
craneWorldX: towerCX * tileSize,
swingAmp,
phase: Math.random() * Math.PI * 2,
phaseSpeed: playStackSwingHz,
floorWorldY,
swingWorldY,
layerWorldH: towerMission ? towerLayerStripH : Math.max(14, tileSize * 0.3),
blockStripH: towerMission ? towerLayerStripH : Math.max(16, tileSize * 0.32),
layers: [],
lives: 3,
teamMissesLeft: towerMission ? missMax : null,
progressPct: 0,
score: 0,
combo: 0,
lastDropAt: 0,
stackTiltRad: 0,
stackTiltVel: 0,
nextDropVisualDirty: true,
pendingDropSeat: 1,
pendingDropHeavy: false,
scorePopups: [],
perfectStackX2Fx: null,
};
if (playBotsEnabled()) {
me.stackPreviewHumanPts = 0;
stackPreviewHudLog = [];
}
ensureStackTowerSlingImagePlay();
if (towerMission || playBotsEnabled()) {
ensureStackTowerLifeHudImagesPlay();
ensureStackTowerHeartMinusImgPlay();
}
updateStackTowerSwingYForStripGapPlay();
}
function stackEaseDrop(t) {
t = Math.max(0, Math.min(1, t));
return t * t;
}
/** ตกช้า/นุ่มขึ้นเมื่อปล่อยเพี้ยนกลาง (คล้าย Tower of Babel — มีน้ำหนักก่อนนิ่ง) */
function stackEaseDropPhys(t, sloppyN) {
t = Math.max(0, Math.min(1, t));
if (sloppyN < 0.14) return t * t;
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* @param {object} hit
* @param {{ human?: boolean, botId?: string } | undefined} previewAttribution — โหมด preview: แยกคะแนนแสดงในแถบ (ตึกยังเป็นของร่วม)
*/
function applyStackHitFromDrop(hit, previewAttribution, layerVisualMeta) {
if (!stackMini || !hit) return;
const towerLive = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live';
if (hit.miss) {
stackMini.combo = 0;
stackMini.scorePopups = [];
stackMini.perfectStackX2Fx = null;
if (stackMini.layers && stackMini.layers.length) {
for (let gi = 0; gi < stackMini.layers.length; gi++) {
const lay = stackMini.layers[gi];
if (lay && lay.perfectGlowUntil != null) delete lay.perfectGlowUntil;
}
}
if (towerLive && stackMini.teamMissesLeft != null) {
stackMini.teamMissesLeft = Math.max(0, stackMini.teamMissesLeft - 1);
} else {
stackMini.lives = Math.max(0, stackMini.lives - 1);
if (stackMini.lives <= 0) {
stackMini.score = Math.max(0, stackMini.score - 2);
stackMini.lives = 3;
stackMini.layers = [];
stackMini.scorePopups = [];
stackMini.perfectStackX2Fx = null;
stackMini.widthTiles = stackMini.initialWidthTiles;
const lb = getStackAreaBoundsPlay(mapData.stackLandArea, mapData.width || 20, mapData.height || 15);
stackMini.topCenterX = lb ? lb.cx : (mapData.width || 20) * 0.5;
stackMini.combo = 0;
stackMini.stackTiltRad = 0;
stackMini.stackTiltVel = 0;
}
}
if (towerLive) triggerStackTowerHeartMinusFxPlay();
} else {
const seatLay = layerVisualMeta && layerVisualMeta.seat != null ? layerVisualMeta.seat : getStackDropActorSeat();
const heavyLay = !!(layerVisualMeta && layerVisualMeta.heavy);
const placedW = hit.placedW != null && Number.isFinite(hit.placedW)
? Math.max(0.85, Math.min(3.2, hit.placedW))
: stackMini.initialWidthTiles;
stackMini.layers.push({ w: placedW, cx: hit.placedCx, seat: seatLay, heavy: heavyLay });
const nAfter = stackMini.layers.length;
maybeQueueStackTowerBgScrollStepPlay(nAfter);
const perfectFull =
towerLive &&
hit.perfect &&
!hit.miss &&
nAfter >= 2 &&
hit.supportRatio != null &&
hit.supportRatio >= STACK_TOWER_PERFECT_SUPPORT_MIN;
if (perfectFull) {
const gUntil = performance.now() + STACK_TOWER_PERFECT_GLOW_MS;
stackMini.layers[nAfter - 1].perfectGlowUntil = gUntil;
stackMini.layers[nAfter - 2].perfectGlowUntil = gUntil;
}
if (towerLive && (perfectFull || hit.pts >= 20)) {
ensureStackTowerPerfectFxImagesPlay();
stackMini.perfectStackX2Fx = {
until: performance.now() + STACK_TOWER_PERFECT_X2_MS,
topLayerIndex: nAfter - 1,
};
} else if (towerLive) {
stackMini.perfectStackX2Fx = null;
}
stackMini.score += hit.pts;
if (towerLive && Number.isFinite(Number(hit.progressDelta)) && (hit.progressDelta || 0) > 0) {
let np = Number(stackMini.progressPct) + Number(hit.progressDelta);
np = Math.min(100, Math.max(0, np));
if (np > 99.999) np = 100;
stackMini.progressPct = np;
markStackNextDropVisualDirty();
}
if (playBotsEnabled() && previewAttribution) {
if (previewAttribution.human) {
me.stackPreviewHumanPts = (me.stackPreviewHumanPts || 0) + hit.pts;
} else if (previewAttribution.botId) {
const ob = others.get(previewAttribution.botId);
if (ob) ob.stackBotScore = (ob.stackBotScore || 0) + hit.pts;
}
}
stackMini.combo = hit.perfect ? stackMini.combo + 1 : 0;
stackMini.topCenterX = hit.placedCx;
if (towerLive && !hit.miss && hit.pts > 0) {
const pNow = Number(stackMini.progressPct);
const prog100 = Number.isFinite(pNow) && pNow >= 99.5;
const kind20 = hit.pts >= 20 || prog100;
if (!stackMini.scorePopups) stackMini.scorePopups = [];
const until = performance.now() + STACK_TOWER_SCORE_POPUP_MS;
stackMini.scorePopups.push({ until, layerIndex: nAfter - 1, kind: kind20 ? '20' : '10' });
if (kind20 && perfectFull && nAfter >= 2) {
stackMini.scorePopups.push({ until, layerIndex: nAfter - 2, kind: '20' });
}
while (stackMini.scorePopups.length > 12) stackMini.scorePopups.shift();
}
}
}
/**
* Editor embed เท่านั้น: ปรับ `stackMini.phase` ให้ sin·swingAmp ใกล้จุดรองรับก่อนปล่อย — บอทฉลาด/กลาง/พลาดบ่อย ต่างค่า aim error
*/
function previewBotAlignStackSwingPhasePlay(m, botPeer) {
if (!playBotsEnabled() || !m || !mapData) return;
const wMap = mapData.width || 20;
const hMap = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
let supportCx;
let supportW;
if (m.layers.length === 0) {
supportCx = land ? land.cx : wMap * 0.5;
supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
} else {
const sup = m.layers[m.layers.length - 1];
supportCx = sup.cx;
const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
supportW = Math.max(0.85, Math.min(3.2, swR));
}
const tier = botPeer && botPeer.botTier === 'sharp' ? 'sharp' : botPeer && botPeer.botTier === 'weak' ? 'weak' : 'avg';
const layerTight = 1 / Math.sqrt(1 + (m.layers.length || 0) * 1.05);
const errMax = tier === 'sharp' ? 0.016 : tier === 'avg' ? 0.038 : 0.17;
const tri = Math.random() + Math.random() - 1;
let errTiles = tri * errMax * layerTight;
if (m.layers.length >= 2) {
const L = m.layers.length;
const step = m.layers[L - 1].cx - m.layers[L - 2].cx;
errTiles -= step * 0.28;
}
let targetCx = supportCx + errTiles;
if (land && m.layers.length > 0) {
const pull = (land.cx - supportCx) * 0.1;
const cap = tier === 'sharp' ? 0.09 : tier === 'avg' ? 0.12 : 0.06;
targetCx += Math.max(-cap, Math.min(cap, pull));
}
const rawWant = targetCx - m.topCenterX;
const amp = Math.max(1e-5, m.swingAmp);
const s = Math.max(-1, Math.min(1, rawWant / amp));
const phi0 = Math.asin(s);
const phi1 = Math.PI - phi0;
const twoPi = Math.PI * 2;
function nearestPhase(anchor, p) {
let best = p;
let bestD = Infinity;
for (let k = -5; k <= 5; k++) {
const cand = p + k * twoPi;
const d = Math.abs(cand - anchor);
if (d < bestD) {
bestD = d;
best = cand;
}
}
return best;
}
const n0 = nearestPhase(m.phase, phi0);
const n1 = nearestPhase(m.phase, phi1);
m.phase = Math.abs(n0 - m.phase) <= Math.abs(n1 - m.phase) ? n0 : n1;
}
/**
* ชั้นรองรับใช้ความกว้างจริงต่อชั้น (L.w) · บล็อกที่กำลังตกใช้ m.widthTiles (ปกติ vs heavy กว้างต่างกัน)
* พลาดเมื่อทับแท่นไม่พอ หรือจุดกลางหลุดขอบฐาน (STACK_MAX_*)
*/
function computeStackDropResult(m) {
const raw = m.swingAmp * Math.sin(m.phase);
const wMap = mapData.width || 20;
const hMap = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, wMap, hMap);
const fallWRaw = m.widthTiles != null && Number.isFinite(m.widthTiles) ? m.widthTiles : m.initialWidthTiles;
const fallW = Math.max(0.85, Math.min(3.2, fallWRaw));
const wMul = getStackTowerBlockWorldScalePlay();
const fallWEff = fallW * wMul;
let supportCx;
let supportW;
if (m.layers.length === 0) {
supportCx = land ? land.cx : wMap * 0.5;
supportW = land ? (land.maxX - land.minX + 1) : Math.max(8, fallW * 2.2);
} else {
const sup = m.layers[m.layers.length - 1];
supportCx = sup.cx;
const swR = sup.w != null && Number.isFinite(sup.w) ? sup.w : m.initialWidthTiles;
supportW = Math.max(0.85, Math.min(3.2, swR));
}
const c1 = m.topCenterX + raw;
const half1 = fallWEff * 0.5;
const half0 = (m.layers.length === 0 ? supportW : supportW * wMul) * 0.5;
const left = Math.max(supportCx - half0, c1 - half1);
const right = Math.min(supportCx + half0, c1 + half1);
const overlap = right - left;
const missThreshold =
m.layers.length === 0
? Math.max(STACK_OVERLAP_MISS_MIN_ON_LAND, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAND)
: Math.max(STACK_OVERLAP_MISS_MIN_ON_LAYER, fallWEff * STACK_OVERLAP_MISS_FRAC_ON_LAYER);
const overlapMiss = overlap < missThreshold;
const placedCx = c1;
let driftMiss = false;
if (!overlapMiss && m.layers.length > 0) {
const anchorCx = land ? land.cx : wMap * 0.5;
let maxDriftTiles;
if (land) {
const landSpan = Math.max(1, land.maxX - land.minX + 1);
maxDriftTiles = landSpan * STACK_MAX_CENTER_DRIFT_FRAC_OF_LAND;
} else {
maxDriftTiles = Math.max(0.85, fallW * STACK_MAX_CENTER_DRIFT_NO_LAND_MULT);
}
if (Math.abs(placedCx - anchorCx) > maxDriftTiles) driftMiss = true;
}
const miss = overlapMiss || driftMiss;
const delta = c1 - supportCx;
const span = supportW * 0.5 + fallW * 0.5;
const offN = Math.abs(delta) / Math.max(0.01, span);
const perfect = !miss && offN < 0.055;
const comboBefore = m.combo || 0;
let pts;
let progressDelta = 0;
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'live') {
const mult = !miss && perfect && comboBefore >= 1 ? 2 : 1;
const progPerLayer = 100 / stackTowerProgressBlocksPlay();
pts = miss ? 0 : (10 * mult);
progressDelta = miss ? 0 : (progPerLayer * mult);
} else {
const bonus = perfect ? Math.min(4, comboBefore) : 0;
pts = perfect ? (2 + bonus) : (miss ? 0 : 1);
}
const supportRatio = fallWEff > 0 ? overlap / fallWEff : 0;
return {
miss,
perfect,
pts,
progressDelta,
landOffsetTiles: raw,
placedW: fallW,
placedCx,
overlap,
supportRatio,
delta,
};
}
/**
* เริ่มแอนิเมชันบล็อกร่วง (ผู้เล่น/บอทใช้ path เดียวกัน)
* @param {object} hit จาก computeStackDropResult
* @param {{ human?: boolean, botId?: string } | null} previewActor โหมด preview: ใครเป็นคนปล่อย (สำหรับคะแนน + เลื่อนตา)
* @returns {boolean}
*/
function startStackFallAnimation(hit, previewActor) {
if (!stackMini || !hit || stackFall || stackMini.settling) return false;
ensureStackNextDropVisual();
const m = stackMini;
const actorSeat = m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat();
const actorHeavy = !!m.pendingDropHeavy;
const tw = m.widthTiles * tileSize * getStackTowerBlockWorldScalePlay();
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
const lh = m.layerWorldH || Math.max(14, tileSize * 0.3);
const swingLift = getStackTowerSwingLiftWorldPx(m.layers.length, lh);
const swingAttachY = m.swingWorldY - swingLift;
let y1 = m.floorWorldY - (m.layers.length + 1) * lh;
if (y1 <= swingAttachY) y1 = swingAttachY + lh * 0.55;
const landOff = hit.landOffsetTiles || 0;
const landCxWorld = (m.topCenterX + landOff) * tileSize;
const xLeft0 = swingXW - tw / 2;
const xLeft1 = landCxWorld - tw / 2;
const offN = Math.min(1, Math.abs(landOff) / Math.max(0.01, m.swingAmp));
const dur = Math.max(400, Math.min(1280, 240 + (y1 - swingAttachY) * 0.92 + offN * 560));
const tiltMax = hit.miss
? 0.28 * (0.25 + offN)
: 0.16 * offN * (hit.perfect ? 0.1 : 1);
const isHumanPreview = !!(previewActor && previewActor.human);
const botIdPreview = previewActor && previewActor.botId ? previewActor.botId : null;
const cableTopYRel = isStackTowerMissionUiMapPlay()
? (swingAttachY - tileSize * 6)
: Math.max(0, swingAttachY - tileSize * 6);
const craneXRel = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
const releaseRopeAng = Math.atan2(swingAttachY - cableTopYRel, swingXW - craneXRel);
stackFall = {
t0: performance.now(),
dur,
xLeft0,
xLeft1,
tw,
y0: swingAttachY,
y1,
hit,
tiltMax,
sloppyN: offN,
previewHumanDrop: isHumanPreview,
previewBotId: botIdPreview,
releaseRopeAng,
releaseAttachWorldX: swingXW,
releaseAttachWorldY: swingAttachY,
actorSeat,
actorHeavy,
};
return true;
}
function stackHumanDropGateOkPlay() {
if (!stackMini || !isStack() || stackFall || stackMini.settling) return false;
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return false;
if (playBotsEnabled() && mapData && mapData.gameType === 'stack' &&
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
rebuildStackPreviewTurnOrder();
}
if (playBotsEnabled() && !isStackPreviewHumanTurn()) return false;
if (Date.now() - stackMini.lastDropAt < 480) return false;
return true;
}
/** @returns {boolean} เริ่มแอนิเมชันร่วงแล้วหรือไม่ */
function tryHumanStackDrop() {
if (!stackHumanDropGateOkPlay()) return false;
ensureStackNextDropVisual();
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { human: true } : null)) return false;
if (playBotsEnabled()) {
lastStackPreviewActorId = '__human__';
lastStackPreviewActorUntil = Date.now() + 900;
}
return true;
}
/** บอท preview: ปล่อยแบบมีแอนิเมชันร่วงเหมือนผู้เล่น — apply หลังแอนิเมชันจบ */
function simulatePreviewBotStackDrop(botId, o) {
if (!stackMini || stackFall || stackMini.settling) return;
ensureStackNextDropVisual();
previewBotAlignStackSwingPhasePlay(stackMini, o);
const hit = computeStackDropResult(stackMini);
if (!startStackFallAnimation(hit, playBotsEnabled() ? { botId } : null)) return;
o.stackBotGlowUntil = Date.now() + Math.max(900, 400 + (stackFall && stackFall.dur ? stackFall.dur : 800));
lastStackPreviewActorId = botId;
lastStackPreviewActorUntil = Date.now() + Math.max(1000, 500 + (stackFall && stackFall.dur ? stackFall.dur : 800));
}
/** Stack preview: สลับตา — เฉพาะบอทที่ถึงตาถึงจะจำลองปล่อยหนึ่งครั้งแล้วเลื่อนตา */
function stepPreviewStackTurnBased(nowMs) {
if (!playBotsEnabled() || !isStack() || !stackMini || stackFall || stackMini.settling) return;
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase !== 'live') return;
if (mapData && mapData.gameType === 'stack' &&
(!stackPreviewTurnOrder || stackPreviewTurnOrder.length !== STACK_PREVIEW_TURN_COUNT)) {
rebuildStackPreviewTurnOrder();
}
const order = stackPreviewTurnOrder;
if (!order || order.length !== STACK_PREVIEW_TURN_COUNT) return;
const cur = order[stackPreviewTurnIndex];
if (!cur || cur.kind !== 'bot') return;
if (!cur.botId) {
advanceStackPreviewTurn();
return;
}
const o = others.get(cur.botId);
if (!o) {
advanceStackPreviewTurn();
return;
}
if (stackPreviewBotThinkUntil === 0) {
const tier = o.botTier === 'sharp' ? 'sharp' : o.botTier === 'weak' ? 'weak' : 'avg';
const thinkLo = tier === 'sharp' ? 160 : tier === 'avg' ? 260 : 340;
const thinkHi = tier === 'sharp' ? 340 : tier === 'avg' ? 520 : 620;
stackPreviewBotThinkUntil = nowMs + thinkLo + Math.random() * (thinkHi - thinkLo);
return;
}
if (nowMs < stackPreviewBotThinkUntil) return;
stackPreviewBotThinkUntil = 0;
simulatePreviewBotStackDrop(cur.botId, o);
}
function stackTickFrame() {
stackTowerMissionMaybeEndPlay();
const now = performance.now();
const dt = Math.min(0.06, (now - lastStackTickMs) / 1000);
lastStackTickMs = now;
if (stackMini && stackMini.settling) {
const s = stackMini.settling;
const u = (now - s.t0) / s.dur;
if (u >= 1) {
const wasHuman = !!s.previewHumanDrop;
const settleBotId = s.previewBotId || null;
applyStackHitFromDrop({ miss: true, perfect: false, pts: 0, landOffsetTiles: 0 }, undefined);
if (playBotsEnabled() && (wasHuman || settleBotId)) {
stackPreviewLogStackDrop({ miss: true, perfect: false, pts: 0 }, { human: wasHuman, botId: settleBotId });
}
stackMini.settling = null;
stackMini.lastDropAt = Date.now();
markStackNextDropVisualDirty();
if (playBotsEnabled() && (wasHuman || settleBotId)) advanceStackPreviewTurn();
}
} else if (stackFall) {
const t = (now - stackFall.t0) / stackFall.dur;
if (t >= 1) {
const wasHuman = !!stackFall.previewHumanDrop;
const fallBotId = stackFall.previewBotId || null;
const hit = stackFall.hit;
const stableMin = STACK_STABLE_SUPPORT_RATIO_MIN;
if (hit.miss) {
applyStackHitFromDrop(hit);
if (playBotsEnabled() && (wasHuman || fallBotId)) {
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
}
stackMini.lastDropAt = Date.now();
stackFall = null;
markStackNextDropVisualDirty();
if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
} else if (hit.supportRatio != null && hit.supportRatio < stableMin) {
const stripH = stackMini.blockStripH || Math.max(16, tileSize * 0.32);
stackMini.settling = {
t0: performance.now(),
dur: Math.max(820, Math.min(2600, 720 + (1 - hit.supportRatio) * 3400)),
hit,
xLeft0: stackFall.xLeft1,
yTop0: stackFall.y1,
twWorld: stackFall.tw,
stripH,
delta: hit.delta || 0,
previewHumanDrop: wasHuman,
previewBotId: fallBotId,
actorSeat: stackFall.actorSeat,
actorHeavy: stackFall.actorHeavy,
};
stackFall = null;
} else {
let previewAttr;
if (playBotsEnabled()) {
if (wasHuman) previewAttr = { human: true };
else if (fallBotId) previewAttr = { botId: fallBotId };
}
applyStackHitFromDrop(hit, previewAttr, { seat: stackFall.actorSeat, heavy: stackFall.actorHeavy });
if (playBotsEnabled() && (wasHuman || fallBotId)) {
stackPreviewLogStackDrop(hit, { human: wasHuman, botId: fallBotId });
}
stackMini.lastDropAt = Date.now();
stackFall = null;
markStackNextDropVisualDirty();
if (playBotsEnabled() && (wasHuman || fallBotId)) advanceStackPreviewTurn();
}
}
} else if (stackMini) {
stackMini.phase += stackMini.phaseSpeed * dt * Math.PI * 2;
}
stepPreviewStackTurnBased(Date.now());
}
/**
* จุดบนจอที่เส้นก้าน→บล็อกตัดแนว y = -(ความสูงแคนวาส × STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC)
* — ปลายเชือกอยู่เหนือขอบบน ~25% ของความสูงจอ (มองเห็นช่วงที่เข้ามาในจอ)
*/
function stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, canvasH) {
const ch = Math.max(80, Number(canvasH) || 1080);
const marginTop = -ch * STACK_TOWER_ROPE_TOP_ABOVE_CANVAS_FRAC;
const ddx = tcx - slingBx;
const ddy = tcy - slingBy;
if (Math.abs(ddy) < 1e-4) {
return { x: tcx, y: Math.min(marginTop, Math.min(tcy, slingBy)) };
}
const vTop = (marginTop - slingBy) / ddy;
if (!Number.isFinite(vTop) || vTop < 0) {
return { x: tcx, y: tcy };
}
const xTop = slingBx + vTop * ddx;
return { x: xTop, y: marginTop };
}
function applyStackPreviewSpawnLayout() {
if (!mapData || mapData.gameType !== 'stack') return;
const w = mapData.width || 20, h = mapData.height || 15;
const land = getStackAreaBoundsPlay(mapData.stackLandArea, w, h);
const cx = land ? land.cx : w / 2 - 0.5;
const cy = land ? land.cy : h / 2 - 0.5;
me.x = cx;
me.y = cy;
me.tx = cx;
me.ty = cy;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.x = cx + (Math.random() - 0.5) * 0.15;
o.y = cy + (Math.random() - 0.5) * 0.15;
o.tx = o.x;
o.ty = o.y;
o.stackBotScore = 0;
o.stackBotNextTry = Date.now() + 300 + Math.random() * 700;
});
}
function drawStackMinigame(ctx, worldToScreen, zoom) {
if (!stackMini) return;
const m = stackMini;
const towerHudLive = isStackTowerMissionHudActivePlay();
const wMul = getStackTowerBlockWorldScalePlay();
ensureStackNextDropVisual();
const layerWorldH = m.layerWorldH || Math.max(14, tileSize * 0.3);
const stripH = m.blockStripH || Math.max(16, tileSize * 0.32);
const floorY = m.floorWorldY;
const swingLiftDraw = getStackTowerSwingLiftWorldPx(m.layers.length, layerWorldH);
const swingDrawY = m.swingWorldY - swingLiftDraw;
const hidLayers = towerHudLive ? getStackTowerVisualHiddenLayerCountPlay() : 0;
const nLay = m.layers.length;
for (let i = hidLayers; i < nLay; i++) {
const L = m.layers[i];
const lw = (L.w != null ? L.w : m.initialWidthTiles) * tileSize * wMul;
const cxW = L.cx * tileSize;
const yb = floorY - (i + 1) * layerWorldH;
const xl = cxW - lw / 2;
const [sx, sy] = worldToScreen(xl, yb);
const drawW = lw * zoom;
const drawH = layerWorldH * zoom;
const hue = (i * 47) % 360;
const seatL = L.seat != null ? L.seat : 1;
const heavyL = !!L.heavy;
if (towerHudLive && L.perfectGlowUntil != null) {
const ntGlow = performance.now();
if (ntGlow < L.perfectGlowUntil) {
drawStackTowerPerfectYellowGlowBehindBlockPlay(ctx, sx, sy, drawW, drawH, ntGlow, L.perfectGlowUntil);
}
}
drawStackBlockSpriteOrHue(ctx, sx, sy, drawW, drawH, seatL, heavyL, hue, {});
}
if (towerHudLive && m.perfectStackX2Fx) {
const fxX2 = m.perfectStackX2Fx;
const ntX = performance.now();
if (ntX >= fxX2.until) {
m.perfectStackX2Fx = null;
} else {
const ti = fxX2.topLayerIndex;
if (ti >= hidLayers && ti >= 0 && ti < m.layers.length) {
const Ltop = m.layers[ti];
const lwT = (Ltop.w != null ? Ltop.w : m.initialWidthTiles) * tileSize * wMul;
const cxWT = Ltop.cx * tileSize;
const ybT = floorY - (ti + 1) * layerWorldH;
const xlT = cxWT - lwT / 2;
const [sxT, syT] = worldToScreen(xlT, ybT);
const drawWT = lwT * zoom;
const drawHT = layerWorldH * zoom;
const fxImX = ensureStackTowerPerfectFxImagesPlay();
if (fxImX.x2) {
drawStackTowerPerfectX2ClusterPlay(ctx, fxImX.light, fxImX.x2, sxT, syT, drawWT, drawHT, ntX, fxX2.until);
}
}
}
}
if (towerHudLive) {
drawStackTowerScorePopupsPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY);
}
if (m.settling) {
const s = m.settling;
const u = Math.min(1, (performance.now() - s.t0) / s.dur);
const ue = u * u;
const rotSign = s.delta >= 0 ? 1 : -1;
const rotRad = ue * 1.22 * rotSign;
const yFall = ue * ue * tileSize * 6.5;
const xSlide = rotSign * ue * tileSize * 0.62;
const cxW = s.xLeft0 + s.twWorld / 2 + xSlide;
const cyW = s.yTop0 + s.stripH * 0.5 + yFall;
const [cxs, cys] = worldToScreen(cxW, cyW);
const drawW = s.twWorld * zoom;
const drawH = s.stripH * zoom;
const hue = (m.layers.length * 47) % 360;
const seatS = s.actorSeat != null ? s.actorSeat : 1;
const heavyS = !!s.actorHeavy;
ctx.save();
ctx.translate(cxs, cys);
ctx.rotate(rotRad);
drawStackBlockSpriteOrHue(ctx, -drawW / 2, -drawH / 2, drawW, drawH, seatS, heavyS, hue, {});
ctx.restore();
} else {
const twWorld = m.widthTiles * tileSize * wMul;
const drawStripPx = stripH * zoom;
const cableTopY = isStackTowerMissionUiMapPlay()
? (swingDrawY - tileSize * 6)
: Math.max(0, swingDrawY - tileSize * 6);
const craneX = m.craneWorldX != null ? m.craneWorldX : m.topCenterX * tileSize;
let blockLeftWorld;
let blockTopWorld;
let fallTiltRad = 0;
if (stackFall) {
const tRaw = (performance.now() - stackFall.t0) / stackFall.dur;
const t = Math.min(1, tRaw);
const u = stackEaseDropPhys(t, stackFall.sloppyN);
blockLeftWorld = stackFall.xLeft0 + (stackFall.xLeft1 - stackFall.xLeft0) * u;
blockTopWorld = stackFall.y0 + (stackFall.y1 - stackFall.y0) * u;
fallTiltRad = stackFall.tiltMax * Math.sin(Math.PI * u);
} else {
const swingXW = (m.topCenterX + m.swingAmp * Math.sin(m.phase)) * tileSize;
blockLeftWorld = swingXW - twWorld / 2;
blockTopWorld = swingDrawY;
}
const cxMid = blockLeftWorld + twWorld / 2;
const [tcx, tcy] = worldToScreen(craneX, cableTopY);
const [bcx, bcy] = worldToScreen(cxMid, blockTopWorld);
let slingBx = bcx;
let slingBy = bcy;
if (stackFall && stackFall.releaseAttachWorldX != null) {
const attY = stackFall.releaseAttachWorldY;
const p = worldToScreen(stackFall.releaseAttachWorldX, attY);
slingBx = p[0];
slingBy = p[1];
}
let ropeSx0 = tcx;
let ropeSy0 = tcy;
let ropeSx1 = slingBx;
let ropeSy1 = slingBy;
if (isStackTowerMissionUiMapPlay()) {
const chCv = ctx.canvas && ctx.canvas.height ? ctx.canvas.height : STACK_TOWER_FIXED_RENDER_H;
const topA = stackTowerRopeScreenTopAnchorPlay(slingBx, slingBy, tcx, tcy, chCv);
ropeSx0 = topA.x;
ropeSy0 = topA.y;
ropeSx1 = slingBx;
ropeSy1 = slingBy;
const t1 = Math.max(0, Math.min(1, STACK_TOWER_ROPE_DRAW_T1));
if (t1 < 1) {
ropeSx1 = ropeSx0 + (slingBx - ropeSx0) * t1;
ropeSy1 = ropeSy0 + (slingBy - ropeSy0) * t1;
}
}
drawStackSlingSegmentPlay(ctx, ropeSx0, ropeSy0, ropeSx1, ropeSy1, zoom);
const ropeAng = stackFall && stackFall.releaseRopeAng != null
? stackFall.releaseRopeAng
: Math.atan2(bcy - ropeSy0, bcx - ropeSx0);
const swingTiltRad = ropeAng - Math.PI / 2;
const hitMissTint = stackFall && stackFall.hit && stackFall.hit.miss;
const seatDraw = stackFall && stackFall.actorSeat != null
? stackFall.actorSeat
: (m.pendingDropSeat != null ? m.pendingDropSeat : getStackDropActorSeat());
const heavyDraw = stackFall ? !!stackFall.actorHeavy : !!m.pendingDropHeavy;
const hueHint = (m.layers.length * 47) % 360;
ctx.save();
ctx.translate(bcx, bcy);
ctx.rotate(fallTiltRad + swingTiltRad);
drawStackBlockSpriteOrHue(ctx, -twWorld * zoom / 2, 0, twWorld * zoom, drawStripPx, seatDraw, heavyDraw, hueHint, { missTint: !!hitMissTint, missOverlay: !!hitMissTint });
ctx.lineWidth = 1;
ctx.restore();
}
if (towerHudLive) {
drawStackTowerHeartMinusFxPlay(ctx, worldToScreen, zoom, m, layerWorldH, floorY, nLay);
}
}
function applyGauntletTimingFromServer(payload) {
if (!payload || typeof payload !== 'object') return;
const tm = Number(payload.gauntletTickMs);
if (Number.isFinite(tm)) gauntletRuntimeTickMs = Math.max(80, Math.min(800, tm));
const jt = Number(payload.gauntletJumpTicks);
if (Number.isFinite(jt)) gauntletRuntimeJumpTicks = Math.max(4, Math.min(40, jt));
const tl = Number(payload.gauntletTimeLimitSec);
if (Number.isFinite(tl)) gauntletRuntimeTimeLimitSec = Math.max(0, Math.min(7200, tl));
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletEndsAt')) {
if (payload.gauntletEndsAt == null) gauntletEndsAtMs = null;
else {
const ge = Number(payload.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) {
gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld;
if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) {
gauntletLaneImageUrls = payload.gauntletLaneImageUrls
.filter((x) => typeof x === 'string')
.map((x) => normalizeGauntletAssetUrlForPlay(x))
.filter((x) => x)
.slice(0, 24);
gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x));
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) {
gauntletLaserTopUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : '');
ensureGauntletAssetImage(gauntletLaserTopUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) {
gauntletLaserBottomUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : '');
ensureGauntletAssetImage(gauntletLaserBottomUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) {
gauntletLaserLineUrl = normalizeGauntletAssetUrlForPlay(typeof payload.gauntletLaserLineUrl === 'string' ? payload.gauntletLaserLineUrl : '');
ensureGauntletAssetImage(gauntletLaserLineUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserFillColor')) {
gauntletLaserFillColor = clampClientLaserColor(payload.gauntletLaserFillColor, gauntletLaserFillColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserStrokeColor')) {
gauntletLaserStrokeColor = clampClientLaserColor(payload.gauntletLaserStrokeColor, gauntletLaserStrokeColor);
}
if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineWidthPx')) {
const lw = Number(payload.gauntletLaserLineWidthPx);
if (Number.isFinite(lw)) gauntletLaserLineWidthPx = Math.max(0, Math.min(24, Math.round(lw)));
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackSwingHz')) {
const sh = Number(payload.stackSwingHz);
if (Number.isFinite(sh)) playStackSwingHz = Math.max(0.08, Math.min(2.8, sh));
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockWidthTiles')) {
const raw = payload.stackBlockWidthTiles;
if (raw == null || raw === '') playStackBlockWidthTiles = null;
else {
const nw = Number(raw);
playStackBlockWidthTiles = Number.isFinite(nw) && nw >= 0.85
? Math.max(0.85, Math.min(3.2, Math.round(nw * 100) / 100))
: null;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerMissionTimeSec')) {
const st = Number(payload.stackTowerMissionTimeSec);
if (Number.isFinite(st) && st > 0) {
playStackTowerMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
} else {
playStackTowerMissionTimeSec = 90;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackTeamMissesMax')) {
const sm = Number(payload.stackTeamMissesMax);
if (Number.isFinite(sm)) {
playStackTeamMissesMax = Math.max(1, Math.min(20, Math.floor(sm)));
} else {
playStackTeamMissesMax = 3;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackTowerProgressBlocks')) {
const pb = Number(payload.stackTowerProgressBlocks);
if (Number.isFinite(pb) && pb >= 1) {
playStackTowerProgressBlocks = Math.max(1, Math.min(500, Math.floor(pb)));
} else {
playStackTowerProgressBlocks = 50;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockNormalImageUrls')) {
playStackBlockNormalUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockNormalImageUrls);
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackBlockHeavyImageUrls')) {
playStackBlockHeavyUrls = normalizePlayStackSixUrlsFromTiming(payload.stackBlockHeavyImageUrls);
}
if (Object.prototype.hasOwnProperty.call(payload, 'stackHeavyBlockPercent')) {
const hp = Number(payload.stackHeavyBlockPercent);
if (Number.isFinite(hp)) {
playStackHeavyBlockPercent = Math.max(0, Math.min(100, Math.floor(hp)));
} else {
playStackHeavyBlockPercent = 35;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveJumpHeightMult')) {
const jm = Number(payload.jumpSurviveJumpHeightMult);
if (Number.isFinite(jm)) {
playJumpSurviveJumpHeightMult = Math.round(Math.max(0.5, Math.min(4, jm)) * 100) / 100;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurviveMissionTimeSec')) {
const jt = Number(payload.jumpSurviveMissionTimeSec);
if (Number.isFinite(jt) && jt > 0) {
playJumpSurviveMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(jt)));
} else {
playJumpSurviveMissionTimeSec = 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTiles')
|| Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')) {
const legacy = Object.prototype.hasOwnProperty.call(payload, 'jumpSurvivePlatformTileUrl')
? normalizeGauntletAssetUrlForPlay(typeof payload.jumpSurvivePlatformTileUrl === 'string' ? payload.jumpSurvivePlatformTileUrl : '')
: '';
const arr = Array.isArray(payload.jumpSurvivePlatformTiles) ? payload.jumpSurvivePlatformTiles : [];
const next = [];
for (let pi = 0; pi < 3; pi++) {
const o = arr[pi] && typeof arr[pi] === 'object' ? arr[pi] : {};
let url = normalizeGauntletAssetUrlForPlay(typeof o.url === 'string' ? o.url : '');
if (pi === 0 && !url && legacy) url = legacy;
let ww = Number(o.w);
let hh = Number(o.h);
if (!Number.isFinite(ww) || ww <= 0) ww = 0;
else ww = Math.max(8, Math.min(4096, Math.round(ww)));
if (!Number.isFinite(hh) || hh <= 0) hh = 0;
else hh = Math.max(8, Math.min(4096, Math.round(hh)));
next.push({ url, w: ww, h: hh });
if (url) ensureGauntletAssetImage(url);
}
playJumpSurvivePlatformTiles = next;
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterMissionTimeSec')) {
const st = Number(payload.spaceShooterMissionTimeSec);
if (Number.isFinite(st) && st > 0) {
playSpaceShooterMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
} else {
playSpaceShooterMissionTimeSec = 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipImageUrls')) {
const a = payload.spaceShooterShipImageUrls;
const next = ['', '', '', '', '', ''];
if (Array.isArray(a)) {
for (let si = 0; si < 6; si++) {
const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
next[si] = u;
if (u) ensureGauntletAssetImage(u);
}
}
playSpaceShooterShipImageUrls = next;
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidSpriteUrls')) {
const raw = payload.spaceShooterAsteroidSpriteUrls;
const next = [];
if (Array.isArray(raw)) {
for (let ai = 0; ai < raw.length && next.length < 32; ai++) {
const u = normalizeGauntletAssetUrlForPlay(typeof raw[ai] === 'string' ? raw[ai] : '');
if (u) next.push(u);
}
}
playSpaceShooterAsteroidSpriteUrls = next;
next.forEach((u) => {
if (u) ensureGauntletAssetImage(u);
});
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidExplodeFrameMs')) {
const ef = Number(payload.spaceShooterAsteroidExplodeFrameMs);
if (Number.isFinite(ef)) {
playSpaceShooterAsteroidExplodeFrameMs = Math.max(30, Math.min(500, Math.round(ef)));
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterAsteroidIntervalMs')) {
const iv = Number(payload.spaceShooterAsteroidIntervalMs);
if (Number.isFinite(iv) && iv >= 200) {
playSpaceShooterAsteroidIntervalMs = Math.min(10000, Math.floor(iv));
} else {
playSpaceShooterAsteroidIntervalMs = 1040;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'spaceShooterShipDamageOverlayUrls')) {
const rawD = payload.spaceShooterShipDamageOverlayUrls;
const nextD = ['', '', ''];
if (Array.isArray(rawD)) {
for (let di = 0; di < 3; di++) {
const u = normalizeGauntletAssetUrlForPlay(typeof rawD[di] === 'string' ? rawD[di] : '');
nextD[di] = u;
if (u) ensureGauntletAssetImage(u);
}
}
playSpaceShooterShipDamageOverlayUrls = nextD;
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) {
const st = Number(payload.balloonBossMissionTimeSec);
if (Number.isFinite(st) && st > 0) {
playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
} else {
playBalloonBossMissionTimeSec = 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) {
playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay(
typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : ''
);
if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
const a = payload.balloonBossPlayerBalloonImageUrls;
const nextBb = ['', '', '', '', '', ''];
if (Array.isArray(a)) {
for (let si = 0; si < 6; si++) {
const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
nextBb[si] = u;
if (u) ensureGauntletAssetImage(u);
}
}
playBalloonBossPlayerBalloonImageUrls = nextBb;
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) {
playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay(
typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : ''
);
if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl);
}
if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
}
/** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */
const GAUNTLET_VIS_LERP = 0.42;
function gauntletObsSmoothstep(t) {
t = Math.max(0, Math.min(1, t));
return t * t * (3 - 2 * t);
}
function cloneGauntletObsSnap(arr) {
if (!Array.isArray(arr)) return [];
return arr.map((o) => {
if (!o || o.id == null) return null;
const row = { id: o.id, kind: o.kind, x: o.x, y: o.y };
if (o.kind === 'laser') {
if (o.y0 != null) row.y0 = o.y0;
if (o.y1 != null) row.y1 = o.y1;
}
return row;
}).filter(Boolean);
}
function gauntletLaserRowHitRange(ob, mapH) {
const h = Math.max(1, Math.floor(Number(mapH)) || 15);
let y0 = ob && ob.y0 != null && Number.isFinite(Number(ob.y0)) ? Math.floor(Number(ob.y0)) : 0;
let y1 = ob && ob.y1 != null && Number.isFinite(Number(ob.y1)) ? Math.floor(Number(ob.y1)) : h - 1;
y0 = Math.max(0, Math.min(h - 1, y0));
y1 = Math.max(0, Math.min(h - 1, y1));
if (y1 < y0) {
const t = y0;
y0 = y1;
y1 = t;
}
return { y0, y1 };
}
function gauntletLaserOverlapsPlayerRow(ob, py, mapH) {
const { y0, y1 } = gauntletLaserRowHitRange(ob, mapH);
return py >= y0 && py <= y1;
}
/** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */
function gauntletLiftHeightNorm(air, jumpTicksMax) {
const jm = Math.max(4, jumpTicksMax || 16);
const a = Math.max(0, Math.min(jm, Number(air) || 0));
const t = a / jm;
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);
o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
px = Math.max(0, px - 1);
}
} else {
px = Math.max(0, px - 1);
}
}
if (crown && mapData) {
const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
if (mx != null && Number.isFinite(mx)) {
const maxPx = Math.floor(mx + 1e-6);
px = Math.min(px, maxPx);
}
}
if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--;
o.tx = px;
o.ty = py;
}
function applyGauntletPreviewBotsAfterSync() {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
const w = mapData.width || 20;
const h = mapData.height || 15;
others.forEach((o, id) => {
if (!isPreviewBotId(id) || !o) return;
maybePreviewBotGauntletJump(o, gauntletObstacles, w, h);
applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h);
});
}
/** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */
function emitGauntletPreviewRowsToServer() {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'gauntlet') return;
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
const h = mapData.height || 15;
const ysSet = new Set();
const addY = (v) => {
const fy = Math.floor(Number(v));
if (Number.isFinite(fy) && fy >= 0 && fy < h) ysSet.add(fy);
};
addY(me.y);
others.forEach((o, id) => {
if (isPreviewBotId(id)) addY(o.y);
});
socket.emit('gauntlet-preview-rows', { ys: [...ysSet] });
}
/** ซ่อน overlay โหมดอื่นที่ค้างจาก embed / session ก่อน — กัน UI ซ้อนไม่ตรง mock */
function hideConflictingOverlaysForGauntletCrown() {
hideStackTowerResultFlashDomOnlyPlay();
hideQuizCarryPregameOverlay();
quizCarryPregameActive = false;
const hideIds = [
'quiz-game-overlay',
'quiz-carry-mission-overlay',
'gauntlet-ended-overlay',
'quiz-battle-mcq-overlay',
'quiz-carry-embed-countdown',
'gauntlet-crown-countdown',
'quiz-carry-embed-q-strip',
'play-quiz-scoreboard',
'play-quiz-feedback',
];
for (let i = 0; i < hideIds.length; i++) {
const el = document.getElementById(hideIds[i]);
if (el) el.classList.add('is-hidden');
}
const gcm = document.getElementById('gauntlet-crown-mission-overlay');
if (gcm) gcm.classList.add('is-hidden');
hideStackTowerHowtoRulesDom();
const gchHowto = document.getElementById('gauntlet-crown-howto-overlay');
if (gchHowto) gchHowto.classList.remove(GCHOWTO_STACK_TOWER_CLASS);
}
function gcmRankBadgeUrl(rank) {
const r = Math.floor(Number(rank)) || 99;
if (r === 1) return BASE + '/img/gauntlet-assets/result-1st.png';
if (r === 2) return BASE + '/img/gauntlet-assets/result-2nd.png';
if (r === 3) return BASE + '/img/gauntlet-assets/result-3rd.png';
return BASE + '/img/gauntlet-assets/result-txt-2.png';
}
function gauntletCrownPregameReadyNumerator() {
let n = quizCarryPregameBotCount();
quizCarryPregameHumanIds().forEach((id) => {
if (gauntletCrownLobbyReadyMap[id]) n++;
});
return n;
}
function gauntletCrownSyncGuestReadyIfNeeded() {
const inQuizQuestionHowto = isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto';
const inStackTowerHowto = isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto';
const inJumpSurviveHowto = isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto';
const inSpaceShooterHowto = isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto';
if (gauntletCrownPregamePhase !== 'howto' && !inQuizQuestionHowto && !inStackTowerHowto && !inJumpSurviveHowto && !inSpaceShooterHowto) return;
if (myId == null || isMePlayHost()) return;
const sid = String(myId);
if (gauntletCrownLobbyReadyMap[sid]) return;
gauntletCrownLobbyReadyMap[sid] = true;
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: true });
}
/** ภารกิจคำถาม mng8a80o — Ready Status / ปุ่ม แบบเดียวกับ Mega Virus (gauntlet-crown-lobby-sync) */
function updateQuizQuestionMissionHowtoHud() {
if (!isQuizQuestionMissionUiMapPlay() || quizQuestionMissionPhase !== 'howto') return;
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!st || !btn) return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
function updateGauntletCrownHowtoHud() {
const st = document.getElementById('gauntlet-crown-howto-status');
const btn = document.getElementById('btn-gch-ready');
if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
const tot = Math.max(1, quizCarryPregameTotalPlayers());
const num = gauntletCrownPregameReadyNumerator();
if (st) {
st.classList.remove('is-hidden');
st.textContent = 'Ready Status : ' + num + '/' + tot;
}
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
btn.classList.toggle('is-start-phase', humansReady);
btn.classList.toggle('is-read-only', !isMePlayHost());
btn.disabled = !isMePlayHost();
btn.setAttribute('aria-pressed', humansReady ? 'false' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'true' : 'false'));
btn.title = isMePlayHost()
? (humansReady ? 'START' : ((myId && gauntletCrownLobbyReadyMap[String(myId)]) ? 'ยกเลิก READY' : 'READY'))
: (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)');
}
function beginGauntletCrownCountdownThenRun() {
if (!usesCrownLobbyShellPlay()) return;
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'countdown';
if (playBotsEnabled() && mapData && mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(false);
}
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
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');
function applyMegaBalloonLiveStartPlay() {
balloonBossSessionStartMs = performance.now();
balloonBossLastTickMs = performance.now();
balloonBossGameEnded = false;
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossBossFireAcc = 0;
balloonBossScorePopups = [];
me.balloonBossScore = 0;
me.balloonBossBossDmg = 0;
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
me.balloonBossEliminated = false;
others.forEach((o) => {
o.balloonBossScore = 0;
o.balloonBossBossDmg = 0;
o.balloonBossBalloons = balloonBossBalloonsStartPlay();
o.balloonBossEliminated = false;
});
if (mapData) applyBalloonBossSpawnLayoutPlay();
}
const runFinish = () => {
if (cd) cd.classList.add('is-hidden');
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
if (socket && socket.connected) {
socket.emit('gauntlet-crown-begin-run', (res) => {
if (res && res.ok) {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
}
});
} else {
gauntletCrownPregamePhase = 'live';
trySnapGauntletCrownRunwayScrollToSpawnsPlay();
if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay();
}
};
if (!cd || !numEl) {
runFinish();
return;
}
cd.classList.remove('is-hidden');
let n = 3;
setCountdown321QuestionAssetGraphic(numEl, n);
const step = () => {
n--;
if (n > 0) {
setCountdown321QuestionAssetGraphic(numEl, n);
gauntletCrownCountdownTimer = setTimeout(step, 1000);
} else {
gauntletCrownCountdownTimer = null;
runFinish();
}
};
gauntletCrownCountdownTimer = setTimeout(step, 1000);
}
function showGauntletCrownHowtoOverlay() {
if (!usesCrownLobbyShellPlay()) return;
hideConflictingOverlaysForGauntletCrown();
if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages();
const cd = document.getElementById('gauntlet-crown-countdown');
if (cd) cd.classList.add('is-hidden');
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
gauntletCrownPregamePhase = 'howto';
resetGauntletCrownRunwaySpawnScrollSnap();
const ov = document.getElementById('gauntlet-crown-howto-overlay');
if (!ov) return;
gauntletCrownHowtoVisible = true;
ov.classList.remove('is-hidden');
if (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 === 'violent_crime') {
const sc = Number(mission.survivorCount);
if (Number.isFinite(sc) && sc <= 0) return false;
const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
return g !== 'F';
}
if (mission.uiSkin === 'jumper') {
const sc = Number(mission.survivorCount);
return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F');
}
if (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') {
const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
return g !== 'F';
}
if (mission.uiSkin === 'mega_virus') {
const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
return g !== 'F';
}
const g = String(mission.grade || '').trim().toUpperCase().charAt(0);
return g !== 'F';
}
function gauntletCrownRankBonusLocal(rankOrdinal) {
if (rankOrdinal === 1) return 100;
if (rankOrdinal === 2) return 80;
if (rankOrdinal === 3) return 60;
return 0;
}
function gauntletCrownRollRewardCardLocal(grade) {
const r = Math.random();
if (grade === 'A') {
if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'B') {
if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
/** พรีวิว: สรุปภารกิจ Last Light หลังรันเวย์ครบ — ให้ gauntletCrownBuildDisplayMission รวมบอท */
function gauntletCrownHeistBuildLocalCrownMissionPlay() {
const baseRows = [];
if (myId != null) {
baseRows.push({
id: myId,
nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ',
characterId: me.characterId ?? null,
baseScore: me.gauntletEliminated ? 0 : Math.max(0, Number(me.gauntletScore) | 0),
eliminated: !!me.gauntletEliminated,
});
}
others.forEach((o, id) => {
if (!o || isPreviewBotId(id)) return;
baseRows.push({
id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id),
characterId: o.characterId ?? null,
baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
eliminated: !!o.gauntletEliminated,
});
});
baseRows.sort((a, b) => {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
const nick = String(a.nickname).localeCompare(String(b.nickname), 'th');
if (nick !== 0) return nick;
return String(a.id).localeCompare(String(b.id));
});
const top = baseRows.slice(0, 5);
const ranked = top.map((row, idx) => {
const pos = idx + 1;
const rankBonus = gauntletCrownRankBonusLocal(pos);
const bs = Math.max(0, Number(row.baseScore) | 0);
return {
id: row.id,
nickname: row.nickname,
characterId: row.characterId,
baseScore: bs,
eliminated: !!row.eliminated,
rank: pos,
rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos),
rankBonus,
finalScore: bs + rankBonus,
};
});
const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const survivorCount = baseRows.filter((r) => !r.eliminated).length;
let grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
if (survivorCount <= 0) grade = 'F';
const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade);
return {
ranked,
totalSum,
averageScore,
grade,
rewardCard,
totalParts: ranked.map((r) => r.finalScore),
participantCount: baseRows.length,
survivorCount,
};
}
/**
* Editor embed + preview bots: รวมแถวอันดับกับบอท (สูงสุด 5 ช่อง) และคำนวณรวม/เฉลี่ย/เกรดให้สอดคล้องกับแถวที่โชว์
* — อันดับในช่องใช้ลำดับ 1..5 หลังเรียงคะแนน (เหมือนแนว quiz_carry mission row)
*/
function gauntletCrownBuildDisplayMission(mission) {
if (!mission || !Array.isArray(mission.ranked)) return mission;
if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission;
if (!(playBotsEnabled() && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission;
const seen = new Set();
const baseRows = [];
mission.ranked.forEach((r) => {
if (!r) return;
const sid = String(r.id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: r.id,
nickname: (r.nickname && String(r.nickname).trim()) ? String(r.nickname).trim() : 'ผู้เล่น',
characterId: r.characterId ?? null,
baseScore: r.eliminated ? 0 : Math.max(0, Number(r.baseScore) || 0),
eliminated: !!r.eliminated,
});
});
others.forEach((o, id) => {
if (!o || !isPreviewBotId(id)) return;
const sid = String(id);
if (seen.has(sid)) return;
seen.add(sid);
baseRows.push({
id: id,
nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid,
characterId: o.characterId ?? null,
baseScore: o.gauntletEliminated ? 0 : Math.max(0, Number(o.gauntletScore) | 0),
eliminated: !!o.gauntletEliminated,
});
});
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;
} else if (mission.uiSkin === 'violent_crime') {
disp = spaceShooterMergePreviewBotsMission(disp) || disp;
} else if (mission.uiSkin === 'question_mission') {
disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp;
} else if (mission.uiSkin === 'stack_tower') {
disp = stackTowerMissionMergePreviewBotsMission(disp) || disp;
} else if (mission.uiSkin === 'mega_virus') {
disp = balloonBossMergePreviewBotsMission(disp) || disp;
}
if (!disp || !Array.isArray(disp.ranked)) return;
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) {
setCyberHudScoreAvatarImg(img, {
id: r.id,
characterId: cid,
isMe: myId != null && r.id != null && String(r.id) === String(myId),
});
} else {
img.src = defaultAvatarImg.src;
}
img.onerror = function () {
this.onerror = null;
this.src = defaultAvatarImg.src;
};
frame.appendChild(img);
const qMissionLose = mission && mission.uiSkin === 'question_mission' && r.hadQuizWrong;
if (r.eliminated || qMissionLose) {
const st = document.createElement('img');
st.className = 'gcm-stamp';
const jSkin = mission && mission.uiSkin === 'jumper';
let loseUrl = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
let altLose = 'เสียชีวิต';
if (jSkin && r.eliminated) {
loseUrl = jumperAssetUrl('stamp-sacrifice.png');
altLose = 'ผู้เสียสละ';
} else if (qMissionLose) {
loseUrl = BASE + '/img/QUESTION/result-Lose_stamp.png';
altLose = 'ตอบผิด';
}
st.src = loseUrl;
st.alt = altLose;
st.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png';
this.alt = 'ตอบผิด';
};
frame.appendChild(st);
}
const nick = document.createElement('div');
nick.className = 'gcm-nick';
nick.textContent = r.nickname || '—';
const sc = document.createElement('div');
sc.className = 'gcm-sc';
const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus');
const botScorePlain = (playBotsEnabled() && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id))
|| (playBotsEnabled() && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id))
|| (playBotsEnabled() && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id))
|| (playBotsEnabled() && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id));
sc.textContent = (jumpPlain || botScorePlain)
? String(Math.max(0, Number(r.baseScore) || 0))
: String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')';
cell.appendChild(tag);
cell.appendChild(frame);
cell.appendChild(nick);
cell.appendChild(sc);
rowEl.appendChild(cell);
});
if (totalEl) {
const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0)));
const sum = Math.max(0, Number(disp.totalSum) || 0);
const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0));
const jumperSkin = mission && mission.uiSkin === 'jumper';
const violentSkin = mission && mission.uiSkin === 'violent_crime';
const questionMissionSkin = mission && mission.uiSkin === 'question_mission';
if (jumperSkin) {
const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด';
} else if (violentSkin) {
const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0));
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · รอดชีวิต ' + sc + '/' + pc + ' → เกรด';
} else if (questionMissionSkin) {
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'คะแนนตอบคำถาม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
} else if (mission && mission.uiSkin === 'stack_tower') {
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
} else if (mission && mission.uiSkin === 'mega_virus') {
const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0));
totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด';
} else {
totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด';
}
}
if (gradeEl) {
const g = String(disp.grade || 'C').toUpperCase().charAt(0);
gradeEl.textContent = g;
gradeEl.className = 'gcm-grade gcm-grade--' + g.toLowerCase();
}
if (bonusEl) {
bonusEl.innerHTML = '';
const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0);
const missionOk = gLetter !== 'F';
if (missionOk) {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '8px';
const chk = document.createElement('img');
const stackTS = mission && mission.uiSkin === 'stack_tower';
const megaVs = mission && mission.uiSkin === 'mega_virus';
chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png'));
chk.alt = '';
if (stackTS || megaVs) {
chk.onerror = function () {
this.onerror = null;
this.src = BASE + '/img/gauntlet-assets/result-check.png';
};
}
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 = '' + String(rc.th) + '' + String(rc.en || '') + '';
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 = 'ไม่ได้รับการ์ดเกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์';
bonusEl.appendChild(pl);
}
}
const gcmHead = document.getElementById('gcm-heading');
if (gcmHead) {
if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) gcmHead.classList.add('sr-only');
else gcmHead.classList.remove('sr-only');
}
ov.classList.remove('is-hidden');
function goLobby() {
ov.classList.add('is-hidden');
if (gcmHead) gcmHead.classList.remove('sr-only');
if (tryFinishDetectiveMinigameAndReturnLobby()) return;
window.location.href = buildRoomLobbyReturnHref();
}
if (btn) {
btn.onclick = function () {
if (previewMode && editorEmbedReturn) {
ov.classList.add('is-hidden');
if (gcmHead) gcmHead.classList.remove('sr-only');
if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus' || mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime')) {
cancelQuizCarryResultEndAfterTimeup();
hideQuizCarryTimeupOnDeskLayer();
hideQuizCarryResultEndLayer();
scheduleEmbedPreviewReturnToLobbyAfterResultEnd();
} else {
showQuizCarryTimeupOnDeskLayer(function () { return gauntletCrownEmbedMissionAnyOk(disp); });
}
} else {
goLobby();
}
};
}
}
function isLobby() { return mapData && mapData.gameType === 'lobby'; }
function isQuiz() { return mapData && mapData.gameType === 'quiz'; }
function getLane(y) {
if (!mapData || !mapData.lanes) return null;
const lane = mapData.lanes.find(l => l.y === y);
return lane || (mapData.lanes[y] != null ? mapData.lanes[y] : null);
}
function getVehiclePositions(lane, width, timeMs) {
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) return [];
const speed = (lane.speed != null ? lane.speed : 1) * 0.05;
const dir = lane.dir === -1 ? -1 : 1;
const spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5);
const period = width + 2;
const count = Math.max(2, Math.floor(period / spacing));
const positions = [];
for (let i = 0; i < count; i++) {
const p = i * spacing + (timeMs * speed * dir) / 60;
const pNorm = ((p % period) + period) % period;
const vx = pNorm - 1;
positions.push(Math.max(-1, Math.min(width, vx)));
}
return positions;
}
function checkFroggerCollision() {
const fy = Math.floor(me.y);
const lane = getLane(fy);
if (!lane) return false;
if (lane.type === 'road') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.1) return true;
}
}
if (lane.type === 'water') {
const positions = getVehiclePositions(lane, mapData.width, Date.now());
let onLog = false;
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - me.x) < 1.2) { onLog = true; break; }
}
if (!onLog) return true;
}
return false;
}
function respawnFrogger() {
const sp = mapData.spawn || { x: 1, y: mapData.height - 1 };
me.x = sp.x; me.y = sp.y;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
}
/** รองรับ x/y เป็น string จาก Socket — ถ้าเช็คแค่ typeof number จะร่วงไป mapData.spawn ทุกครั้ง (จุดเกิดไม่สุ่ม) */
function peerXYFromJoin(peer, spawnFb) {
const sx = Number(spawnFb && spawnFb.x);
const sy = Number(spawnFb && spawnFb.y);
const defX = Number.isFinite(sx) ? sx : 1;
const defY = Number.isFinite(sy) ? sy : 1;
if (!peer) return { x: defX, y: defY };
const x = Number(peer.x);
const y = Number(peer.y);
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
return { x: defX, y: defY };
}
/** Hub = 0/1 · กริดตัวเลือก = 0 หรือเลข 1..16 (ห้ามบังคับ === 1 เหมือน hub — เลข 2–16จะหาย) */
function normalizeQuizCarryLayersInPlay(md) {
if (!md || md.gameType !== 'quiz_carry') return;
const w = md.width || 20, h = md.height || 15;
const maxOpt = QUIZ_CARRY_MAX_OPTION_SLOTS;
const srcHub = md.quizCarryHubArea || [];
const hubRows = [];
for (let y = 0; y < h; y++) {
const r = srcHub[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
hubRows.push(row);
}
md.quizCarryHubArea = hubRows;
const srcOpt = md.quizCarryOptionArea || [];
const optRows = [];
for (let y = 0; y < h; y++) {
const r = srcOpt[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= maxOpt ? Math.floor(n) : 0);
}
optRows.push(row);
}
md.quizCarryOptionArea = optRows;
const srcCd = md.carryEmbedCountdownArea || [];
const cdRows = [];
for (let y = 0; y < h; y++) {
const r = srcCd[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
row.push(Number(v) === 1 ? 1 : 0);
}
cdRows.push(row);
}
md.carryEmbedCountdownArea = cdRows;
}
/** โดม = ช่องติดกัน (4 ทิศ) ต่อ 1 ข้อ · comp id เรียงตามการค้นพบบนแมป */
function normalizeQuizBattleDomeInPlay(md) {
if (!md || md.gameType !== 'quiz_battle') return;
const w = md.width || 20, h = md.height || 15;
const src = md.quizBattleDomeArea || [];
const grid = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
grid.push(row);
}
md.quizBattleDomeArea = grid;
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 normalizeJumpSurvivePlatformVariantAreaInPlay(md) {
if (!md || md.gameType !== 'jump_survive') return;
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea || [];
const src = md.jumpSurvivePlatformVariantArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const row = [];
for (let x = 0; x < w; x++) {
const has = pa[y] && pa[y][x] === 1;
let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
if (has && (!Number.isFinite(v) || v < 1)) v = 1;
if (v > 3) v = 3;
if (!has) v = 0;
row.push(v);
}
rows.push(row);
}
md.jumpSurvivePlatformVariantArea = rows;
}
function jumpSurvivePlatformVariantIndexAtPlay(md, tx, ty) {
const pa = md && md.jumpSurvivePlatformArea;
if (!pa || !pa[ty] || pa[ty][tx] !== 1) return 0;
const va = md.jumpSurvivePlatformVariantArea;
let v = va && va[ty] ? Math.floor(Number(va[ty][tx])) : 1;
if (!Number.isFinite(v) || v < 1) v = 1;
if (v > 3) v = 3;
return v;
}
function normalizeJumpSurviveHazardAreaInPlay(md) {
if (!md || md.gameType !== 'jump_survive') return;
const w = md.width || 20, h = md.height || 15;
const src = md.jumpSurviveHazardArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
md.jumpSurviveHazardArea = rows;
}
/** โซนตาย jump_survive — กริดคงที่ในโลก (ไม่เลื่อนกับแพลตฟอร์ม) · ช่องที่มีแพลตฟอร์มอยู่ด้วย = ไม่นับ hazard (ยืนบนแพลตฟอร์มชนะ) */
function jumpSurviveOverlapsHazardArea(md, left, top, right, bottom) {
const ha = md && md.jumpSurviveHazardArea;
if (!ha) return false;
const pa = md && md.jumpSurvivePlatformArea;
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const y0 = Math.max(0, Math.floor(top / ts));
const y1 = Math.min(h - 1, Math.floor((bottom - 1e-6) / ts));
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!ha[ty] || ha[ty][tx] !== 1) continue;
if (pa && pa[ty] && pa[ty][tx] === 1) continue;
return true;
}
}
return false;
}
function normalizeShooterSpawnSlotsInPlay(md) {
if (!md || (md.gameType !== 'space_shooter' && md.gameType !== 'jump_survive')) return;
const w = md.width || 20, h = md.height || 15;
const src = md.shooterSpawnSlots || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
}
rows.push(row);
}
md.shooterSpawnSlots = rows;
}
function getShooterSpawnWorldCenterFromMap(md, slot1to6, ts) {
if (!md || !md.shooterSpawnSlots) return null;
const w = md.width || 20, h = md.height || 15;
const g = md.shooterSpawnSlots;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
}
}
return null;
}
function buildSpaceShooterParticipantRefsPlay() {
const refs = [];
if (me) refs.push({ id: myId, ref: me });
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
humanIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
botIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
return refs.filter((r) => r.ref);
}
function applySpaceShooterSpawnLayoutPlay() {
if (!mapData || mapData.gameType !== 'space_shooter') return;
normalizeShooterSpawnSlotsInPlay(mapData);
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const { cw, ch } = getCharacterFootprintWH(mapData);
const mw = w * ts, mh = h * ts;
const refs = buildSpaceShooterParticipantRefsPlay();
const n = refs.length || 1;
refs.forEach((entry, idx) => {
const ent = entry.ref;
const slot = (idx % 6) + 1;
const cen = getShooterSpawnWorldCenterFromMap(mapData, slot, ts);
let cx, cy;
if (cen) {
cx = cen.cx;
cy = cen.cy;
} else {
cx = ((idx + 1) / (n + 1)) * mw;
cy = Math.min(mh - ts * 1.2, (h - 1.5) * ts);
}
cx = Math.max(ts * 0.5, Math.min(mw - ts * 0.5, cx));
cy = clampSpaceShooterWorldCy(cy, mh);
ent.spaceShooterCx = cx;
ent.spaceShooterCy = cy;
ent.spaceShooterSlot = slot;
if (typeof ent.spaceShooterScore !== 'number' || !Number.isFinite(ent.spaceShooterScore)) ent.spaceShooterScore = 0;
ent.x = cx / ts - cw * 0.5;
ent.y = cy / ts - ch * 0.92;
ent.tx = ent.x;
ent.ty = ent.y;
ent.direction = 'up';
});
}
function spaceShooterTimeLimitSecPlay() {
const t = Number(mapData && mapData.spaceShooterTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const g = Number(playSpaceShooterMissionTimeSec);
if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
return 90;
}
function spaceShooterRemainingSecPlay() {
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return null;
const lim = spaceShooterTimeLimitSecPlay();
if (lim <= 0) return null;
const elapsed = (performance.now() - spaceShooterSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function endSpaceShooterTimeUp() {
if (spaceShooterGameEnded || !mapData || mapData.gameType !== 'space_shooter') return;
if (isSpaceShooterMissionUiMapPlay()) {
endSpaceShooterMissionRound('time_up');
return;
}
spaceShooterGameEnded = true;
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterAsteroidExplosions = [];
spaceShooterSpawnAccMs = 0;
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) titleEl.textContent = 'หมดเวลา · Time up';
msgEl.textContent = 'ยิงยานอวกาศจบแล้ว · SPACE SHOOTER finished — อันดับจากคะแนนยิงหิน';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.spaceShooterScore | 0) });
}
others.forEach((o, id) => {
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.spaceShooterScore | 0) });
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
}
function syncSpaceShooterFootFromShipCenter(ent) {
if (!ent || !mapData || ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
ent.x = ent.spaceShooterCx / ts - cw * 0.5;
ent.y = ent.spaceShooterCy / ts - ch * 0.92;
}
/** จำกัดกลางยานแนวตั้งให้อยู่แค่ครึ่งล่างของแมพ (ไม่ขึ้นเกินกึ่งกลางความสูง) */
function clampSpaceShooterWorldCy(cy, mhPx) {
if (!Number.isFinite(cy) || !Number.isFinite(mhPx) || mhPx <= 0) return cy;
const edge = 20;
const lo = mhPx * 0.5;
const hi = Math.max(lo + 4, mhPx - edge);
return Math.max(lo, Math.min(hi, cy));
}
const SPACE_SHOOTER_ASTEROID_INTERVAL_MIN = 200;
/** ms ระหว่างเกิดอุกาบาต — แมปทับถ้าตั้ง ≥200 */
function spaceShooterAsteroidSpawnIntervalMsPlay() {
const mapI = Number(mapData && mapData.spaceShooterAsteroidIntervalMs);
if (Number.isFinite(mapI) && mapI >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) {
return Math.min(10000, Math.floor(mapI));
}
const g = Number(playSpaceShooterAsteroidIntervalMs);
if (Number.isFinite(g) && g >= SPACE_SHOOTER_ASTEROID_INTERVAL_MIN) return Math.min(10000, Math.floor(g));
return 1040;
}
function stepSpaceShooterPreviewBots(dt) {
if (spaceShooterGameEnded || !playBotsEnabled() || !mapData || mapData.gameType !== 'space_shooter') return;
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
const ts = tileSize;
const mw = (mapData.width || 20) * ts;
const mh = (mapData.height || 15) * ts;
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o || o.spaceShooterCx == null || o.spaceShooterEliminated) return;
let nearest = null, nd = 1e9;
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
const a = spaceShooterAsteroids[i];
if (!a) continue;
const d = Math.abs(a.x - o.spaceShooterCx) + Math.abs(a.y - o.spaceShooterCy) * 0.15;
if (d < nd) { nd = d; nearest = a; }
}
let vx = 0;
if (nearest) vx = nearest.x > o.spaceShooterCx ? 1 : nearest.x < o.spaceShooterCx ? -1 : 0;
else vx = Math.sin(performance.now() / 900 + bid.length) > 0 ? 1 : -1;
let vy = 0;
if (nearest && Math.abs(nearest.y - o.spaceShooterCy) > ts * 0.25) {
vy = nearest.y > o.spaceShooterCy ? 1 : -1;
} else if (!nearest) {
vy = Math.sin(performance.now() / 700 + bid.charCodeAt(0)) > 0 ? 1 : -1;
}
const spd = (o.botTier === 'sharp' ? 195 : o.botTier === 'weak' ? 115 : 155);
const padX = spaceShooterShipEdgePadWorldPxPlay();
o.spaceShooterCx = Math.max(padX, Math.min(mw - padX, o.spaceShooterCx + vx * spd * dt));
o.spaceShooterCy = clampSpaceShooterWorldCy(o.spaceShooterCy + vy * spd * 0.7 * dt, mh);
syncSpaceShooterFootFromShipCenter(o);
o.tx = o.x;
o.ty = o.y;
if (typeof o.spaceShooterBotFireCd !== 'number') o.spaceShooterBotFireCd = 0;
o.spaceShooterBotFireCd -= dt;
if (o.spaceShooterBotFireCd <= 0 && nearest && nd < ts * 5) {
o.spaceShooterBotFireCd = o.botTier === 'sharp' ? 0.22 : o.botTier === 'weak' ? 0.48 : 0.34;
spaceShooterBullets.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 16, vy: -500, ownerId: bid });
}
});
}
function spaceShooterTickFrame() {
if (!mapData || mapData.gameType !== 'space_shooter') return;
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase !== 'live') return;
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const mw = w * ts, mh = h * ts;
const { cw, ch } = getCharacterFootprintWH(mapData);
const now = performance.now();
const dt = Math.min(0.055, spaceShooterLastTickMs ? (now - spaceShooterLastTickMs) / 1000 : 0.016);
spaceShooterLastTickMs = now;
tickPlayScrollBg(dt);
spaceShooterTickAsteroidExplosions(dt);
if (spaceShooterGameEnded) {
const wallClock = Date.now();
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
}
return;
}
const remSec = spaceShooterRemainingSecPlay();
if (remSec != null && remSec <= 0) {
endSpaceShooterTimeUp();
return;
}
const interval = spaceShooterAsteroidSpawnIntervalMsPlay();
spaceShooterSpawnAccMs += dt * 1000;
while (spaceShooterSpawnAccMs >= interval) {
spaceShooterSpawnAccMs -= interval;
const maxHp = 1 + Math.floor(Math.random() * SPACE_SHOOTER_ASTEROID_MAX_HP);
const hp0 = maxHp;
spaceShooterAsteroids.push({
x: 36 + Math.random() * (mw - 72),
y: -45 - Math.random() * 90,
r: spaceShooterAsteroidRadiusFromHpPlay(hp0),
vy: 58 + Math.random() * 62,
maxHp: maxHp,
hp: hp0,
});
}
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
if (o.tx != null) o.x += (o.tx - o.x) * 0.28;
if (o.ty != null) o.y += (o.ty - o.y) * 0.28;
o.spaceShooterCx = (o.x + cw * 0.5) * ts;
o.spaceShooterCy = clampSpaceShooterWorldCy((o.y + ch * 0.92) * ts, mh);
});
let vx = 0;
let vy = 0;
if (!isChatFocused()) {
if (keys['ArrowLeft'] || keys['KeyA']) vx -= 1;
if (keys['ArrowRight'] || keys['KeyD']) vx += 1;
if (keys['ArrowUp'] || keys['KeyW']) vy -= 1;
if (keys['ArrowDown'] || keys['KeyS']) vy += 1;
}
const moveSpd = 280;
const canPilotMe = !(isSpaceShooterMissionUiMapPlay() && me.spaceShooterEliminated);
if (canPilotMe) {
if (me.spaceShooterCx != null) {
const padX = spaceShooterShipEdgePadWorldPxPlay();
me.spaceShooterCx = Math.max(padX, Math.min(mw - padX, me.spaceShooterCx + vx * moveSpd * dt));
}
if (me.spaceShooterCy != null) {
me.spaceShooterCy = clampSpaceShooterWorldCy(me.spaceShooterCy + vy * moveSpd * dt, mh);
}
syncSpaceShooterFootFromShipCenter(me);
}
spaceShooterFireCd -= dt;
const autoFireOk = canPilotMe && !isChatFocused();
if (autoFireOk && spaceShooterFireCd <= 0 && me.spaceShooterCy != null) {
spaceShooterFireCd = 0.21;
spaceShooterBullets.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 20, vy: -580, ownerId: myId });
}
stepSpaceShooterPreviewBots(dt);
for (let i = spaceShooterBullets.length - 1; i >= 0; i--) {
const b = spaceShooterBullets[i];
b.y += b.vy * dt;
if (b.y < -50 || b.x < -30 || b.x > mw + 30) spaceShooterBullets.splice(i, 1);
}
for (let ai = spaceShooterAsteroids.length - 1; ai >= 0; ai--) {
const a = spaceShooterAsteroids[ai];
a.y += a.vy * dt;
if (a.y > mh + 100) spaceShooterAsteroids.splice(ai, 1);
}
spaceShooterMissionResolveShipAsteroidHitsPlay();
for (let bi = spaceShooterBullets.length - 1; bi >= 0; bi--) {
const b = spaceShooterBullets[bi];
let hit = -1;
for (let ai = 0; ai < spaceShooterAsteroids.length; ai++) {
const a = spaceShooterAsteroids[ai];
const dx = b.x - a.x, dy = b.y - a.y;
const rr = (a.r + 7) * (a.r + 7);
if (dx * dx + dy * dy <= rr) { hit = ai; break; }
}
if (hit >= 0) {
const a = spaceShooterAsteroids[hit];
const mh = Math.max(1, Math.floor(Number(a.maxHp)) || SPACE_SHOOTER_ASTEROID_MAX_HP);
a.hp = Math.max(0, Math.floor(Number(a.hp) || mh) - 1);
spaceShooterBullets.splice(bi, 1);
const add = 5;
if (b.ownerId === myId) {
me.spaceShooterScore = Math.max(0, (me.spaceShooterScore || 0) + add);
spaceShooterPopups.push({ x: me.spaceShooterCx, y: me.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
} else {
const o = others.get(b.ownerId);
if (o) {
o.spaceShooterScore = Math.max(0, (o.spaceShooterScore || 0) + add);
spaceShooterPopups.push({ x: o.spaceShooterCx, y: o.spaceShooterCy - 30, text: '+5', until: Date.now() + 700 });
}
}
if (a.hp <= 0) {
spaceShooterSpawnAsteroidExplosion(a.x, a.y, a.r);
spaceShooterAsteroids.splice(hit, 1);
}
/** ขนาดคงที่จนแตก — ไม่ย่อ r ตาม HP ที่เหลือ (สลับสปรายต์ผ่าน spaceShooterAsteroidLiveSpriteIndexPlay) */
}
}
const wallClock = Date.now();
for (let pi = spaceShooterPopups.length - 1; pi >= 0; pi--) {
/* until ใช้ Date.now() — ห้ามเทียบกับ performance.now() (สเกลคนละระบบ → ลบไม่ออก) */
if (wallClock >= spaceShooterPopups[pi].until) spaceShooterPopups.splice(pi, 1);
}
me.direction = 'up';
me.isWalking = vx !== 0 || vy !== 0;
const tEmit = Date.now();
if (tEmit - spaceShooterLastMoveEmit > 90 && socket && myId != null) {
spaceShooterLastMoveEmit = tEmit;
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
spaceShooterScore: Math.max(0, me.spaceShooterScore | 0),
});
}
}
function drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
if (!mapData || !isSpaceShooter()) return;
const refs = buildSpaceShooterParticipantRefsPlay();
const wallNow = typeof timeMs === 'number' ? timeMs : Date.now();
const astUrls = playSpaceShooterAsteroidSpriteUrls;
for (let i = 0; i < spaceShooterAsteroids.length; i++) {
const a = spaceShooterAsteroids[i];
const [sx, sy] = worldToScreen(a.x, a.y);
const sr = Math.max(6, a.r * zDraw);
const si = spaceShooterAsteroidLiveSpriteIndexPlay(a);
let liveUrl = (astUrls && astUrls[si]) ? String(astUrls[si]).trim() : '';
if (!liveUrl && si > 0 && astUrls && astUrls[0]) liveUrl = String(astUrls[0]).trim();
const liveRec = liveUrl ? ensureGauntletAssetImage(liveUrl) : null;
const liveAstImg = liveRec && liveRec.ready && liveRec.img && liveRec.img.naturalWidth > 0 ? liveRec.img : null;
if (liveAstImg) {
const iw = liveAstImg.naturalWidth;
const ih = liveAstImg.naturalHeight;
const diam = sr * 2.15;
const sc = Math.min(diam / iw, diam / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.drawImage(liveAstImg, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
} else {
const grd = ctx.createRadialGradient(sx - sr * 0.25, sy - sr * 0.25, sr * 0.1, sx, sy, sr);
grd.addColorStop(0, 'rgba(90, 85, 95, 0.95)');
grd.addColorStop(0.55, 'rgba(35, 32, 42, 0.98)');
grd.addColorStop(1, 'rgba(18, 16, 24, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 90, 60, 0.75)';
ctx.lineWidth = Math.max(1.5, zDraw * 1.2);
ctx.beginPath();
ctx.arc(sx, sy, sr * 0.88, 0.6, 1.9);
ctx.stroke();
ctx.beginPath();
ctx.arc(sx + sr * 0.15, sy + sr * 0.1, sr * 0.35, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowColor = 'rgba(255, 60, 40, 0.55)';
ctx.shadowBlur = sr * 0.45;
ctx.strokeStyle = 'rgba(255, 120, 80, 0.35)';
ctx.beginPath();
ctx.arc(sx, sy, sr + 3 * zDraw, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
}
}
if (astUrls && astUrls.length >= 2) {
for (let ei = 0; ei < spaceShooterAsteroidExplosions.length; ei++) {
const ex = spaceShooterAsteroidExplosions[ei];
const u = astUrls[ex.fi + 1];
if (!u) continue;
const rec = ensureGauntletAssetImage(u);
const img = rec && rec.ready && rec.img && rec.img.naturalWidth > 0 ? rec.img : null;
if (!img) continue;
const [sx, sy] = worldToScreen(ex.x, ex.y);
const sr = Math.max(6, ex.r * zDraw);
const iw = img.naturalWidth;
const ih = img.naturalHeight;
const diam = sr * 2.4;
const sc = Math.min(diam / iw, diam / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.drawImage(img, sx - dw * 0.5, sy - dh * 0.5, dw, dh);
}
}
for (let i = 0; i < spaceShooterBullets.length; i++) {
const b = spaceShooterBullets[i];
for (let k = 0; k < 4; k++) {
const t = k / 4;
const yy = b.y + t * 22;
const [bx, by] = worldToScreen(b.x, yy);
ctx.fillStyle = `rgba(255, ${180 - k * 35}, ${80 - k * 15}, ${0.95 - k * 0.18})`;
ctx.beginPath();
ctx.arc(bx, by, Math.max(2, 3.2 * zDraw - k * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
const shipBody = spaceShooterShipBodyScreenPxPlay(zDraw);
const bodyW = shipBody.bodyW;
const bodyH = shipBody.bodyH;
refs.forEach((entry, idx) => {
const ent = entry.ref;
if (ent.spaceShooterCx == null || ent.spaceShooterCy == null) return;
const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy);
const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length];
const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated);
const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1)));
const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim();
const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null;
const shipImg = shipRec && shipRec.ready && shipRec.img && shipRec.img.naturalWidth > 0 ? shipRec.img : null;
let shipDrawW = bodyW * 2.2;
let shipDrawH = bodyH * 2.2;
if (shipImg) {
ctx.save();
ctx.translate(sx, sy);
if (elimShip) ctx.globalAlpha = 0.38;
const iw = shipImg.naturalWidth;
const ih = shipImg.naturalHeight;
const maxW = bodyW * 2.4;
const maxH = bodyH * 2.4;
const sc = Math.min(maxW / iw, maxH / ih);
const dw = iw * sc;
const dh = ih * sc;
shipDrawW = dw;
shipDrawH = dh;
ctx.drawImage(shipImg, -dw * 0.5, -dh * 0.5, dw, dh);
ctx.restore();
} else {
ctx.save();
ctx.translate(sx, sy);
if (elimShip) ctx.globalAlpha = 0.38;
ctx.fillStyle = col;
ctx.strokeStyle = 'rgba(180, 255, 255, 0.85)';
ctx.lineWidth = Math.max(1.2, zDraw);
ctx.beginPath();
ctx.moveTo(0, -bodyH * 0.55);
ctx.lineTo(-bodyW * 0.55, bodyH * 0.42);
ctx.lineTo(0, bodyH * 0.22);
ctx.lineTo(bodyW * 0.55, bodyH * 0.42);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
ctx.beginPath();
ctx.moveTo(-bodyW * 0.22, bodyH * 0.38);
ctx.lineTo(0, bodyH * 0.72);
ctx.lineTo(bodyW * 0.22, bodyH * 0.38);
ctx.closePath();
ctx.fill();
ctx.restore();
}
if (isSpaceShooterMissionUiMapPlay()) {
const hits = Math.max(0, Math.min(3, Number(ent.spaceShooterHits) || 0));
if (hits > 0 && playSpaceShooterShipDamageOverlayUrls && playSpaceShooterShipDamageOverlayUrls.length) {
const oUrl = (playSpaceShooterShipDamageOverlayUrls[hits - 1] || '').trim();
if (oUrl) {
const oRec = ensureGauntletAssetImage(oUrl);
const oImg = oRec && oRec.ready && oRec.img && oRec.img.naturalWidth > 0 ? oRec.img : null;
if (oImg) {
ctx.save();
if (elimShip) ctx.globalAlpha = 0.38;
const ow = oImg.naturalWidth;
const oh = oImg.naturalHeight;
const scO = Math.min(shipDrawW / ow, shipDrawH / oh);
const odw = ow * scO;
const odh = oh * scO;
ctx.drawImage(oImg, sx - odw * 0.5, sy - odh * 0.5, odw, odh);
ctx.restore();
}
}
}
}
const name = (ent.nickname || '').slice(0, 10);
if (name) {
ctx.font = `${Math.max(9, 10 * zDraw)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(224, 242, 255, 0.92)';
ctx.strokeStyle = 'rgba(0, 20, 40, 0.85)';
ctx.lineWidth = 3;
ctx.strokeText(name, sx, sy - bodyH * 0.62);
ctx.fillText(name, sx, sy - bodyH * 0.62);
ctx.textAlign = 'left';
}
});
for (let i = 0; i < spaceShooterPopups.length; i++) {
const p = spaceShooterPopups[i];
if (wallNow >= p.until) continue;
const [px, py] = worldToScreen(p.x, p.y);
const dur = 700;
const age = 1 - Math.max(0, Math.min(1, (p.until - wallNow) / dur));
ctx.save();
ctx.globalAlpha = Math.max(0, 1 - age * 0.92);
ctx.font = `bold ${Math.max(12, 16 * zDraw)}px Orbitron, ui-sans-serif, sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = '#ffe066';
ctx.strokeStyle = 'rgba(120, 60, 0, 0.6)';
ctx.lineWidth = 3;
ctx.strokeText(p.text || '+5', px, py - age * 18);
ctx.fillText(p.text || '+5', px, py - age * 18);
ctx.restore();
}
}
const BALLOON_BOSS_PLAYER_COLORS = ['#ff79c6', '#7aa2f7', '#f9e2af', '#f7768e', '#9ece6a', '#bb9af7'];
function normalizeBalloonBossPlayerSlotsInPlay(md) {
if (!md || md.gameType !== 'balloon_boss') return;
const w = md.width || 20, h = md.height || 15;
const src = md.balloonBossPlayerSlots || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= 6 ? Math.floor(n) : 0);
}
rows.push(row);
}
md.balloonBossPlayerSlots = rows;
}
function getBalloonBossPlayerSpawnWorldCenterFromMap(md, slot1to6, ts) {
if (!md || !md.balloonBossPlayerSlots) return null;
const w = md.width || 20, h = md.height || 15;
const g = md.balloonBossPlayerSlots;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (g[y] && g[y][x] === slot1to6) return { cx: (x + 0.5) * ts, cy: (y + 0.5) * ts };
}
}
return null;
}
function getBalloonBossBossWorldCenterPlay(md, ts) {
const bx = md && md.balloonBossBossSpawn;
if (bx && Number.isFinite(Number(bx.x)) && Number.isFinite(Number(bx.y))) {
return { cx: (Number(bx.x) + 0.5) * ts, cy: (Number(bx.y) + 0.5) * ts };
}
const w = md.width || 20, h = md.height || 15;
return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
}
/**
* จุดกลางบอสระหว่างเล่น — ลอยแบบลูกโป่งจากผลรวม sine (deterministic ต่อเวลาในรอบ)
* เพื่อให้ทุก client ได้ตำแหน่งใกล้เคียงกัน (ไม่ใช้ state สุ่มต่อเครื่อง)
*/
function getBalloonBossBossLiveCenterPlay(md, ts) {
if (!md || md.gameType !== 'balloon_boss') return getBalloonBossBossWorldCenterPlay(md, ts);
const base = getBalloonBossBossWorldCenterPlay(md, ts);
const w = md.width || 20, h = md.height || 15;
const mw = w * ts, mh = h * ts;
const tSec = balloonBossSessionStartMs > 0
? (performance.now() - balloonBossSessionStartMs) / 1000
: performance.now() / 1000;
const ax = Math.min(mw * 0.19, 240);
const ay = Math.min(mh * 0.17, 200);
const cx = base.cx + Math.sin(tSec * 0.33 + 0.75) * ax + Math.sin(tSec * 0.69 + 0.15) * (ax * 0.4);
const cy = base.cy + Math.cos(tSec * 0.29 + 1.05) * ay + Math.cos(tSec * 0.64 + 0.45) * (ay * 0.37);
return clampBalloonBossWorld(cx, cy, mw, mh);
}
/** จุดโลกประมาณกลางกระจุกลูกโป่ง (ใช้ hit จากกระสุนบอส) — ไม่ใช่จุดเท้า */
function balloonBossBalloonClusterWorldPlay(ent, bossCx, bossCy) {
if (!ent || ent.balloonBossCx == null) return { cx: 0, cy: 0 };
const dx = ent.balloonBossCx - bossCx;
const dy = ent.balloonBossCy - bossCy;
const L = Math.hypot(dx, dy) || 1;
const ox = (dx / L) * 22;
const oy = (dy / L) * 12;
return {
cx: ent.balloonBossCx + ox,
cy: ent.balloonBossCy - 52 + oy * 0.35,
};
}
/** ผู้เล่นหลักเด้งกับวงชนบอส (บอสเป็นวงนิ่งใน world) */
function balloonBossPlayerBossElasticPlay(meEnt, bossCx, bossCy, bossCollideR, mw, mh) {
if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
const rMe = 44;
const rB = Math.max(30, bossCollideR * 0.9);
const dx = meEnt.balloonBossCx - bossCx;
const dy = meEnt.balloonBossCy - bossCy;
const dist = Math.hypot(dx, dy) || 1e-6;
const minD = rMe + rB;
if (dist >= minD) return;
const nx = dx / dist;
const ny = dy / dist;
const push = (minD - dist) * 0.58;
meEnt.balloonBossCx += nx * push;
meEnt.balloonBossCy += ny * push;
const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
if (vn < 0) {
meEnt.balloonBossVelX -= 1.85 * vn * nx;
meEnt.balloonBossVelY -= 1.85 * vn * ny;
}
const cl = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
meEnt.balloonBossCx = cl.cx;
meEnt.balloonBossCy = cl.cy;
}
/** ผู้เล่นหลักเด้งกับคนอื่น (บอทขยับรับแรงปะทะเล็กน้อย) */
function balloonBossPlayerElasticCollisionsPlay(meEnt, aliveRefs, mw, mh) {
if (!meEnt || meEnt.balloonBossEliminated || meEnt.balloonBossCx == null) return;
const rMe = 44;
const rO = 44;
const minD = rMe + rO - 2;
for (let ii = 0; ii < aliveRefs.length; ii++) {
const entry = aliveRefs[ii];
if (!entry || entry.ref === meEnt) continue;
const o = entry.ref;
if (!o || o.balloonBossEliminated || o.balloonBossCx == null) continue;
const dx = meEnt.balloonBossCx - o.balloonBossCx;
const dy = meEnt.balloonBossCy - o.balloonBossCy;
const dist = Math.hypot(dx, dy);
if (!dist || dist >= minD) continue;
const nx = dx / dist;
const ny = dy / dist;
const push = (minD - dist) * 0.52;
meEnt.balloonBossCx += nx * push;
meEnt.balloonBossCy += ny * push;
const vn = meEnt.balloonBossVelX * nx + meEnt.balloonBossVelY * ny;
if (vn < 0) {
meEnt.balloonBossVelX -= 1.85 * vn * nx;
meEnt.balloonBossVelY -= 1.85 * vn * ny;
}
if (isPreviewBotId(entry.id)) {
const pushO = (minD - dist) * 0.48;
o.balloonBossCx -= nx * pushO;
o.balloonBossCy -= ny * pushO;
}
}
const cl2 = clampBalloonBossWorld(meEnt.balloonBossCx, meEnt.balloonBossCy, mw, mh);
meEnt.balloonBossCx = cl2.cx;
meEnt.balloonBossCy = cl2.cy;
}
/** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */
function getBalloonBossOccupiedSlotNumbersPlay(md) {
if (!md || !md.balloonBossPlayerSlots) return [];
const g = md.balloonBossPlayerSlots;
const w = md.width || 20, h = md.height || 15;
const seen = new Set();
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const v = g[y] && g[y][x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n));
}
}
return Array.from(seen).sort((a, b) => a - b);
}
function buildBalloonBossParticipantRefsPlay() {
const refs = [];
if (me) refs.push({ id: myId, ref: me });
const humanIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
humanIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
botIds.forEach((id) => {
const o = others.get(id);
if (o) refs.push({ id, ref: o });
});
return refs.filter((r) => r.ref);
}
function balloonBossBalloonsStartPlay() {
const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
/** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */
return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3));
}
function balloonBossMaxHpPlay() {
const n = Number(mapData && mapData.balloonBossMaxHp);
return Math.max(20, Math.min(9999, Number.isFinite(n) ? Math.floor(n) : 100));
}
function balloonBossFireDelayMsPlay() {
const n = Number(mapData && mapData.balloonBossFireDelayMs);
return Math.max(120, Math.min(2000, Number.isFinite(n) ? Math.floor(n) : 380));
}
/** ความเร่งยาน (px/s²) — ต่ำ = คุมยาก ลื่น */
function balloonBossShipAccelPlay() {
const n = Number(mapData && mapData.balloonBossShipAccel);
return Math.max(80, Math.min(520, Number.isFinite(n) ? n : 210));
}
/** ความเร็วสูงสุดยาน (px/s) */
function balloonBossShipMaxSpeedPlay() {
const n = Number(mapData && mapData.balloonBossShipMaxSpeed);
return Math.max(60, Math.min(320, Number.isFinite(n) ? n : 158));
}
/** แรงหน่วงต่อวินาที (velocity *= 1 - min(1, drag*dt)) — สูง = หยุดเร็วขึ้น */
function balloonBossShipDragPlay() {
const n = Number(mapData && mapData.balloonBossShipDrag);
return Math.max(0.4, Math.min(6, Number.isFinite(n) ? n : 1.65));
}
function balloonBossTimeLimitSecPlay() {
const t = Number(mapData && mapData.balloonBossTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const g = Number(playBalloonBossMissionTimeSec);
if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
return 120;
}
function balloonBossRemainingSecPlay() {
const lim = balloonBossTimeLimitSecPlay();
if (lim <= 0) return null;
if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim;
if (!balloonBossSessionStartMs) return lim;
const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000;
return Math.max(0, Math.ceil(lim - elapsed));
}
function balloonBossTeamDamagePlay() {
let s = Math.max(0, me.balloonBossScore | 0);
others.forEach((o) => { s += Math.max(0, o.balloonBossScore | 0); });
return s;
}
/** รวมดาเมจที่ทำกับบอส (ไม่เท่ากับคะแนน — คะแนน +10 ต่อครั้ง, HP บอส -2 ต่อครั้ง) */
function balloonBossTeamBossDamagePlay() {
let s = Math.max(0, me.balloonBossBossDmg | 0);
others.forEach((o) => { s += Math.max(0, o.balloonBossBossDmg | 0); });
return s;
}
function syncBalloonBossFootFromCenter(ent) {
if (!ent || !mapData || ent.balloonBossCx == null || ent.balloonBossCy == null) return;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
ent.x = ent.balloonBossCx / ts - cw * 0.5;
ent.y = ent.balloonBossCy / ts - ch * 0.92;
}
function clampBalloonBossWorld(cx, cy, mw, mh) {
const e = 22;
return {
cx: Math.max(e, Math.min(mw - e, cx)),
cy: Math.max(e, Math.min(mh - e, cy)),
};
}
function applyBalloonBossSpawnLayoutPlay() {
if (!mapData || mapData.gameType !== 'balloon_boss') return;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const { cw, ch } = getCharacterFootprintWH(mapData);
const mw = w * ts, mh = h * ts;
const refs = buildBalloonBossParticipantRefsPlay();
const n = refs.length || 1;
const bStart = balloonBossBalloonsStartPlay();
const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData);
refs.forEach((entry, idx) => {
const ent = entry.ref;
const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((idx % 6) + 1);
const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
let cx, cy;
if (cen) {
cx = cen.cx;
cy = cen.cy;
} else {
const t = (idx + 1) / (n + 1);
cx = t * mw;
cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
}
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
ent.balloonBossSkinSlot = slot;
ent.balloonBossCx = cl.cx;
ent.balloonBossCy = cl.cy;
if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
if (typeof ent.balloonBossBossDmg !== 'number' || !Number.isFinite(ent.balloonBossBossDmg)) ent.balloonBossBossDmg = 0;
/** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */
ent.balloonBossBalloons = bStart;
if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
ent.balloonBossVelX = 0;
ent.balloonBossVelY = 0;
ent.x = ent.balloonBossCx / ts - cw * 0.5;
ent.y = ent.balloonBossCy / ts - ch * 0.92;
ent.tx = ent.x;
ent.ty = ent.y;
ent.direction = 'down';
if (typeof ent.balloonBossAimRad !== 'number' || !Number.isFinite(ent.balloonBossAimRad)) {
ent.balloonBossAimRad = Math.random() * Math.PI * 2;
}
});
}
function endBalloonBossGame(reason) {
if (balloonBossGameEnded || !mapData || mapData.gameType !== 'balloon_boss') return;
balloonBossGameEnded = true;
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossScorePopups = [];
if (isMegaVirusMissionShellMapPlay()) {
applyMegaVirusMissionPanelImages();
beginBalloonBossMegaVirusResultFlashSequenceThenGcm(balloonBossBuildMissionPayloadPlay(reason), reason);
return;
}
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) {
titleEl.textContent = reason === 'victory' ? 'ชนะบอส! · Boss defeated' : 'หมดเวลา · Time up';
}
msgEl.textContent = reason === 'victory'
? 'MEGA VIRUS ถูกกำจัดแล้ว — อันดับจากคะแนนการยิงโดน'
: 'หมดเวลา — อันดับจากคะแนนการยิงโดน';
listEl.innerHTML = '';
const ranks = [];
if (myId != null) {
ranks.push({ id: myId, nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', score: Math.max(0, me.balloonBossScore | 0) });
}
others.forEach((o, id) => {
ranks.push({ id, nickname: (o && o.nickname) ? String(o.nickname).trim() : id, score: Math.max(0, o.balloonBossScore | 0) });
});
ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th'));
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)} pts`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
}
function stepBalloonBossPreviewBots(dt, mw, mh, bossCx, bossCy, ts) {
if (!playBotsEnabled() || !mapData || mapData.gameType !== 'balloon_boss') return;
[...others.keys()].filter(isPreviewBotId).forEach((bid) => {
const o = others.get(bid);
if (!o || o.balloonBossEliminated || o.balloonBossCx == null) return;
const wob = Math.sin(performance.now() / 800 + bid.length) * 0.55;
const wob2 = Math.cos(performance.now() / 1100 + bid.charCodeAt(0)) * 0.45;
const spd = 48;
let nx = o.balloonBossCx + wob * spd * dt;
let ny = o.balloonBossCy + wob2 * spd * 0.72 * dt;
const cl = clampBalloonBossWorld(nx, ny, mw, mh);
o.balloonBossCx = cl.cx;
o.balloonBossCy = cl.cy;
syncBalloonBossFootFromCenter(o);
o.direction = 'down';
o.tx = o.x;
o.ty = o.y;
if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
else o.balloonBossBotNextFire = 0.8 + Math.random() * 0.9;
if (o.balloonBossBotNextFire <= 0) {
o.balloonBossBotNextFire = 0.9 + Math.random() * 1.1;
const delayMs = balloonBossFireDelayMsPlay();
const dx = bossCx - o.balloonBossCx;
const dy = bossCy - o.balloonBossCy;
const L = Math.hypot(dx, dy) || 1;
const bspd = 500 + Math.random() * 90;
balloonBossPendingShots.push({
releaseAt: performance.now() + delayMs,
sx: o.balloonBossCx, sy: o.balloonBossCy,
vx: (dx / L) * bspd,
vy: (dy / L) * bspd,
ownerId: bid,
});
}
});
}
function balloonBossTickFrame() {
if (!mapData || mapData.gameType !== 'balloon_boss') return;
if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return;
const w = mapData.width || 20, h = mapData.height || 15;
const ts = tileSize;
const mw = w * ts, mh = h * ts;
const { cw, ch } = getCharacterFootprintWH(mapData);
others.forEach((o, id) => {
if (isPreviewBotId(id)) return;
if (!o || o.balloonBossEliminated) return;
if (o.tx != null) o.x += (o.tx - o.x) * 0.24;
if (o.ty != null) o.y += (o.ty - o.y) * 0.24;
const cx = (o.x + cw * 0.5) * ts;
const cy = (o.y + ch * 0.92) * ts;
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
o.balloonBossCx = cl.cx;
o.balloonBossCy = cl.cy;
});
const now = performance.now();
const dt = Math.min(0.055, balloonBossLastTickMs ? (now - balloonBossLastTickMs) / 1000 : 0.016);
balloonBossLastTickMs = now;
const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
const bossR = Math.max(36, ts * 1.15);
const maxHp = balloonBossMaxHpPlay();
const teamBossDmg = balloonBossTeamBossDamagePlay();
if (me.balloonBossEliminated) {
me.balloonBossVelX = 0;
me.balloonBossVelY = 0;
}
if (balloonBossGameEnded) {
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
balloonBossHitFx[hi].t -= dt;
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
}
for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
const sp = balloonBossScorePopups[si];
sp.t -= dt;
if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
}
return;
}
const remSec = balloonBossRemainingSecPlay();
if (remSec != null && remSec <= 0) {
endBalloonBossGame('time');
return;
}
if (teamBossDmg >= maxHp) {
endBalloonBossGame('victory');
return;
}
const allRefsEarly = buildBalloonBossParticipantRefsPlay();
if (allRefsEarly.length > 0 && allRefsEarly.every((e) => e.ref && e.ref.balloonBossEliminated)) {
endBalloonBossGame('all_dead');
return;
}
for (let hi = balloonBossHitFx.length - 1; hi >= 0; hi--) {
balloonBossHitFx[hi].t -= dt;
if (balloonBossHitFx[hi].t <= 0) balloonBossHitFx.splice(hi, 1);
}
for (let si = balloonBossScorePopups.length - 1; si >= 0; si--) {
const sp = balloonBossScorePopups[si];
sp.t -= dt;
if (sp.t <= 0) balloonBossScorePopups.splice(si, 1);
}
const aliveRefs = allRefsEarly.filter((e) => e.ref && !e.ref.balloonBossEliminated);
for (let pi = balloonBossPendingShots.length - 1; pi >= 0; pi--) {
const pnd = balloonBossPendingShots[pi];
if (now >= pnd.releaseAt) {
balloonBossPlayerBullets.push({ x: pnd.sx, y: pnd.sy, vx: pnd.vx, vy: pnd.vy, ownerId: pnd.ownerId });
balloonBossPendingShots.splice(pi, 1);
}
}
const bossFireEvery = 1.42;
balloonBossBossFireAcc += dt;
while (balloonBossBossFireAcc >= bossFireEvery) {
balloonBossBossFireAcc -= bossFireEvery;
if (aliveRefs.length) {
const ang = Math.random() * Math.PI * 2;
const spd = 88 + Math.random() * 72;
balloonBossBossBullets.push({
x: bossC.cx, y: bossC.cy,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
gy: 48 + Math.random() * 56,
});
}
}
function applyBalloonBossHitToEntity(ent) {
if (!ent || ent.balloonBossEliminated) return;
if (ent.balloonBossHitIframe > now) return;
ent.balloonBossHitIframe = now + 520;
ent.balloonBossBalloons = Math.max(0, (ent.balloonBossBalloons | 0) - 1);
if (ent.balloonBossBalloons <= 0) {
ent.balloonBossEliminated = true;
ent.balloonBossBalloons = 0;
}
if (ent === me && socket) {
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
balloonBossBalloons: me.balloonBossBalloons,
balloonBossEliminated: !!me.balloonBossEliminated,
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
});
}
}
for (let bi = balloonBossBossBullets.length - 1; bi >= 0; bi--) {
const b = balloonBossBossBullets[bi];
const gy = typeof b.gy === 'number' && Number.isFinite(b.gy) ? b.gy : 62;
b.vy += gy * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < -80 || b.x > mw + 80 || b.y < -80 || b.y > mh + 80) {
balloonBossBossBullets.splice(bi, 1);
continue;
}
let hit = false;
aliveRefs.forEach((entry) => {
if (hit) return;
const ent = entry.ref;
if (!ent || ent.balloonBossCx == null) return;
const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
const dx = b.x - cc.cx, dy = b.y - cc.cy;
if (dx * dx + dy * dy <= (36 * 36)) {
hit = true;
applyBalloonBossHitToEntity(ent);
}
});
if (hit) balloonBossBossBullets.splice(bi, 1);
}
for (let i = balloonBossPlayerBullets.length - 1; i >= 0; i--) {
const b = balloonBossPlayerBullets[i];
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < -60 || b.x > mw + 60 || b.y < -60 || b.y > mh + 60) {
balloonBossPlayerBullets.splice(i, 1);
continue;
}
let pHit = false;
for (let ji = 0; ji < aliveRefs.length; ji++) {
if (pHit) break;
const entry = aliveRefs[ji];
const ent = entry.ref;
if (!ent || ent.balloonBossCx == null) continue;
if (String(entry.id) === String(b.ownerId)) continue;
const canBalloonHit = isPreviewBotId(entry.id) || ent === me;
if (!canBalloonHit) continue;
const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
const pdx = b.x - cc.cx, pdy = b.y - cc.cy;
if (pdx * pdx + pdy * pdy <= (34 * 34)) {
applyBalloonBossHitToEntity(ent);
balloonBossPlayerBullets.splice(i, 1);
pHit = true;
}
}
if (pHit) continue;
const dx = b.x - bossC.cx, dy = b.y - bossC.cy;
if (dx * dx + dy * dy <= bossR * bossR) {
balloonBossHitFx.push({ x: bossC.cx, y: bossC.cy, t: 0.22 });
balloonBossScorePopups.push({
ownerId: b.ownerId,
x: b.x,
y: b.y,
t: 0.85,
tMax: 0.85,
});
const addScore = 10;
const addBossDmg = 2;
if (b.ownerId === myId) {
me.balloonBossScore = Math.max(0, (me.balloonBossScore | 0) + addScore);
me.balloonBossBossDmg = Math.max(0, (me.balloonBossBossDmg | 0) + addBossDmg);
if (socket && myId != null) {
balloonBossLastMoveEmit = Date.now();
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
balloonBossEliminated: !!me.balloonBossEliminated,
});
}
} else {
const o = others.get(b.ownerId);
if (o) {
o.balloonBossScore = Math.max(0, (o.balloonBossScore | 0) + addScore);
o.balloonBossBossDmg = Math.max(0, (o.balloonBossBossDmg | 0) + addBossDmg);
}
}
balloonBossPlayerBullets.splice(i, 1);
}
}
const tWind = performance.now() * 0.001;
const gustAx = Math.sin(tWind * 0.36 + 1.15) * 62 + Math.sin(tWind * 1.05) * 24;
const gustAy = Math.cos(tWind * 0.39 + 0.28) * 58 + Math.cos(tWind * 0.93) * 22;
const maxSpd = Math.min(228, balloonBossShipMaxSpeedPlay() * 1.32);
const dragPerSec = balloonBossShipDragPlay() * 0.4;
if (typeof me.balloonBossVelX !== 'number' || !Number.isFinite(me.balloonBossVelX)) me.balloonBossVelX = 0;
if (typeof me.balloonBossVelY !== 'number' || !Number.isFinite(me.balloonBossVelY)) me.balloonBossVelY = 0;
let nvx = me.balloonBossVelX;
let nvy = me.balloonBossVelY;
nvx += gustAx * dt;
nvy += gustAy * dt;
const drag = Math.min(1, dragPerSec * dt);
nvx *= (1 - drag);
nvy *= (1 - drag);
const curSpd = Math.sqrt(nvx * nvx + nvy * nvy);
if (curSpd > maxSpd) {
const s = maxSpd / curSpd;
nvx *= s;
nvy *= s;
}
me.balloonBossVelX = nvx;
me.balloonBossVelY = nvy;
if (!me.balloonBossEliminated && me.balloonBossCx != null) {
const ncx = me.balloonBossCx + nvx * dt;
const ncy = me.balloonBossCy + nvy * dt;
const cl = clampBalloonBossWorld(ncx, ncy, mw, mh);
if (Math.abs(cl.cx - ncx) > 0.5) me.balloonBossVelX *= -0.88;
if (Math.abs(cl.cy - ncy) > 0.5) me.balloonBossVelY *= -0.88;
me.balloonBossCx = cl.cx;
me.balloonBossCy = cl.cy;
}
syncBalloonBossFootFromCenter(me);
balloonBossPlayerElasticCollisionsPlay(me, aliveRefs, mw, mh);
balloonBossPlayerBossElasticPlay(me, bossC.cx, bossC.cy, bossR, mw, mh);
syncBalloonBossFootFromCenter(me);
/** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */
me.direction = 'down';
me.isWalking = Math.abs(me.balloonBossVelX) > 12 || Math.abs(me.balloonBossVelY) > 12;
/** วงลูกศรหมุนเองเรื่อย ๆ (คุมไม่ได้) — Mega Virus ยิงตามมุมลูกศร ณ ตอนกด Space */
aliveRefs.forEach((e) => {
const r = e.ref;
if (!r || r.balloonBossEliminated) return;
if (typeof r.balloonBossAimRad !== 'number' || !Number.isFinite(r.balloonBossAimRad)) {
r.balloonBossAimRad = Math.random() * Math.PI * 2;
}
r.balloonBossAimRad += 1.02 * dt;
});
balloonBossPlayerFireCd -= dt;
const wantFire = !isChatFocused() && !!keys['Space'] && !me.balloonBossEliminated;
if (wantFire && balloonBossPlayerFireCd <= 0 && me.balloonBossCy != null) {
balloonBossPlayerFireCd = 0.35;
const delayMs = balloonBossFireDelayMsPlay();
const bspd = 480;
let vx;
let vy;
if (isMegaVirusMissionShellMapPlay()) {
const aim = (typeof me.balloonBossAimRad === 'number' && Number.isFinite(me.balloonBossAimRad))
? me.balloonBossAimRad
: Math.atan2(bossC.cy - me.balloonBossCy, bossC.cx - me.balloonBossCx);
vx = Math.cos(aim) * bspd;
vy = Math.sin(aim) * bspd;
} else {
const dx = bossC.cx - me.balloonBossCx;
const dy = bossC.cy - me.balloonBossCy;
const L = Math.hypot(dx, dy) || 1;
vx = (dx / L) * bspd;
vy = (dy / L) * bspd;
}
balloonBossPendingShots.push({
releaseAt: now + delayMs,
sx: me.balloonBossCx, sy: me.balloonBossCy,
vx,
vy,
ownerId: myId,
});
}
stepBalloonBossPreviewBots(dt, mw, mh, bossC.cx, bossC.cy, ts);
const tEmit = Date.now();
if (tEmit - balloonBossLastMoveEmit > 95 && socket && myId != null) {
balloonBossLastMoveEmit = tEmit;
socket.emit('move', {
x: me.x, y: me.y, direction: me.direction,
balloonBossScore: Math.max(0, me.balloonBossScore | 0),
balloonBossBossDmg: Math.max(0, me.balloonBossBossDmg | 0),
balloonBossBalloons: Math.max(0, me.balloonBossBalloons | 0),
balloonBossEliminated: !!me.balloonBossEliminated,
});
}
}
function drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs) {
if (!mapData || !isBalloonBoss()) return;
const ts = tileSize;
const w = mapData.width || 20, h = mapData.height || 15;
const mw = w * ts, mh = h * ts;
const bossC = getBalloonBossBossLiveCenterPlay(mapData, ts);
const maxHp = balloonBossMaxHpPlay();
const dmg = balloonBossTeamBossDamagePlay();
const hpLeft = Math.max(0, maxHp - dmg);
const bossR = Math.max(36, ts * 1.15);
const refs = buildBalloonBossParticipantRefsPlay();
const leaderId = (() => {
let best = null, bs = -1;
refs.forEach((e) => {
if (!e.ref || e.ref.balloonBossEliminated) return;
const sc = Math.max(0, e.ref.balloonBossScore | 0);
if (sc > bs) { bs = sc; best = e.id; }
});
return best;
})();
const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || ''));
const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null;
for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
const fx = balloonBossHitFx[hi];
const [hx, hy] = worldToScreen(fx.x, fx.y);
const pulse = 1 + (fx.t || 0) * 3;
ctx.save();
ctx.globalAlpha = Math.min(1, (fx.t || 0) * 4);
ctx.strokeStyle = 'rgba(255, 220, 120, 0.9)';
ctx.lineWidth = 3 * zDraw;
ctx.beginPath();
ctx.arc(hx, hy, bossR * zDraw * 0.5 * pulse, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const score10PopUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/score+10.png');
const score10PopRec = score10PopUrl ? ensureGauntletAssetImage(score10PopUrl) : null;
for (let si = 0; si < balloonBossScorePopups.length; si++) {
const sp = balloonBossScorePopups[si];
let wx = sp.x;
let wy = sp.y;
if (sp.ownerId != null) {
const oid = sp.ownerId;
const ent = (myId != null && String(oid) === String(myId)) ? me : others.get(oid);
if (ent && ent.balloonBossCx != null && !ent.balloonBossEliminated) {
const cc = balloonBossBalloonClusterWorldPlay(ent, bossC.cx, bossC.cy);
const tMax = typeof sp.tMax === 'number' && sp.tMax > 0 ? sp.tMax : 0.95;
const rise = ((tMax - Math.max(0, sp.t || 0)) / tMax) * 58;
wx = cc.cx;
wy = cc.cy - 46 - rise;
}
}
const [sx, sy] = worldToScreen(wx, wy);
ctx.save();
ctx.globalAlpha = Math.min(1, Math.max(0, sp.t || 0) * 1.12);
if (sp.dmgTxt) {
const fpx = Math.max(11, 14 * zDraw);
ctx.font = `bold ${fpx}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = 'rgba(0, 0, 0, 0.62)';
ctx.lineWidth = Math.max(2, 2.4 * zDraw);
ctx.strokeText(String(sp.dmgTxt), sx, sy);
ctx.fillStyle = '#f8fafc';
ctx.fillText(String(sp.dmgTxt), sx, sy);
} else if (score10PopRec && score10PopRec.ready && score10PopRec.img && score10PopRec.img.naturalWidth > 0) {
const iw = score10PopRec.img.naturalWidth;
const ih = score10PopRec.img.naturalHeight;
const mh = Math.max(14, 20 * zDraw);
const sc = Math.min(1.35, mh / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.drawImage(score10PopRec.img, sx - dw / 2, sy - dh / 2, dw, dh);
}
ctx.restore();
}
const bossAlive = hpLeft > 0;
if (bossAlive) {
const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
ctx.save();
ctx.translate(bsx, bsy);
const limR = bossR * zDraw;
if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) {
const iw = bossImgRec.img.naturalWidth;
const ih = bossImgRec.img.naturalHeight;
const diam = limR * 2.1;
const scale = Math.min(diam / iw, diam / ih);
const dw = iw * scale;
const dh = ih * scale;
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
} else {
ctx.beginPath();
for (let k = 0; k < 6; k++) {
const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(ang) * bossR * zDraw * 1.05;
const py = Math.sin(ang) * bossR * zDraw * 1.05;
if (k === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
ctx.fill();
const skullR = bossR * zDraw * 0.62;
const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(0, 0, skullR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
ctx.shadowBlur = 12 * zDraw;
ctx.beginPath();
ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
/** HUD บอส: แถบชิดเหนือหัวบอส; โลโก้ Artboard10 + [hp/max] เหนือแถบ — bbHudS ลดสเกลทั้งแถว (ขอให้เล็กลง ~ครึ่ง) */
const bbHudS = 0.5;
const fontPx = Math.max(8, 12.5 * zDraw * bbHudS);
ctx.font = `bold ${fontPx}px ui-monospace, monospace`;
ctx.textBaseline = 'middle';
const hpStr = '[ ' + hpLeft + ' / ' + maxHp + ' ]';
const hpW = ctx.measureText(hpStr).width;
const gapHp = 5 * zDraw * bbHudS;
const powBgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power-bg.png');
const powFillUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/boss-power.png');
const powBgRec = powBgUrl ? ensureGauntletAssetImage(powBgUrl) : null;
const powFillRec = powFillUrl ? ensureGauntletAssetImage(powFillUrl) : null;
/** ความสูงหลอด: ยึดความกว้างก่อน — คูณ bbHudS ให้สัดส่วนเทียบหัวบอสเล็กลง */
const barHMin = Math.max(9, 10.5 * zDraw) * bbHudS;
const barHMax = Math.max(17, 21 * zDraw) * bbHudS;
const titleImgUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/Artboard%2010.png');
const titleImgRec = titleImgUrl ? ensureGauntletAssetImage(titleImgUrl) : null;
let imgW = 0;
let imgH = 0;
if (titleImgRec && titleImgRec.ready && titleImgRec.img && titleImgRec.img.naturalWidth > 0) {
const iw = titleImgRec.img.naturalWidth;
const ih = titleImgRec.img.naturalHeight;
let barHForTitle = barHMin;
if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
const biw = powBgRec.img.naturalWidth;
const bijh = powBgRec.img.naturalHeight;
const wEst = Math.max(limR * 2.12 * bbHudS, hpW + gapHp + iw * 0.35 * bbHudS);
barHForTitle = Math.min(barHMax, Math.max(barHMin, bijh * (wEst / biw)));
}
const capTitle = Math.min(17 * zDraw * bbHudS, Math.max(fontPx * 1.15, barHForTitle * 0.88));
const maxH = Math.max(fontPx * 1.12, 14 * zDraw * bbHudS, capTitle);
const sc = Math.min(1.0, maxH / ih);
imgW = iw * sc;
imgH = ih * sc;
}
let rowW = (imgW > 0 ? imgW + gapHp : 0) + hpW;
if (imgW <= 0) {
const legacyPre = 'MEGA VIRUS : ';
rowW = ctx.measureText(legacyPre).width + hpW;
}
const barPadRow = 8 * zDraw * bbHudS;
const barWTarget = Math.max(limR * 2.08 * bbHudS, rowW + barPadRow);
let barDw = barWTarget;
let barDh = barHMin;
if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
const iw = powBgRec.img.naturalWidth;
const ih = powBgRec.img.naturalHeight;
const naturalH = ih * (barWTarget / iw);
barDh = Math.min(barHMax, Math.max(barHMin, naturalH));
}
const gapBarBoss = 4 * zDraw * bbHudS;
const gapTitleBar = 5 * zDraw * bbHudS;
const barBottomEdge = -limR * 1.0 - gapBarBoss;
const barTopY = barBottomEdge - barDh;
const bx = -barDw / 2;
const by = barTopY;
const ratHp = Math.max(0, Math.min(1, hpLeft / maxHp));
const titleRowH = Math.max(imgH, fontPx * 1.12);
const labelY = barTopY - gapTitleBar - titleRowH * 0.5;
let xLeft = -(imgW + (imgW > 0 ? gapHp : 0) + hpW) / 2;
if (imgW > 0) {
ctx.drawImage(titleImgRec.img, xLeft, labelY - imgH / 2, imgW, imgH);
xLeft += imgW + gapHp;
} else {
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
const legacyPre = 'MEGA VIRUS : ';
const pw = ctx.measureText(legacyPre).width;
xLeft = -(pw + hpW) / 2;
ctx.fillText(legacyPre, xLeft, labelY);
xLeft += pw;
}
ctx.textAlign = 'left';
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
ctx.lineWidth = Math.max(0.5, 1 * zDraw * bbHudS);
ctx.strokeText(hpStr, xLeft, labelY);
ctx.fillText(hpStr, xLeft, labelY);
if (powBgRec && powBgRec.ready && powBgRec.img && powBgRec.img.naturalWidth > 0) {
ctx.drawImage(powBgRec.img, bx, by, barDw, barDh);
if (ratHp > 0 && powFillRec && powFillRec.ready && powFillRec.img && powFillRec.img.naturalWidth > 0) {
const fiw = powFillRec.img.naturalWidth;
const fih = powFillRec.img.naturalHeight;
const padIn = barDw * 0.04;
const innerW = barDw - padIn * 2;
const innerH = barDh - padIn * 1.2;
const fsc2 = Math.min(innerW / fiw, innerH / fih);
const fdw2 = fiw * fsc2;
const fdh2 = fih * fsc2;
const fx0 = bx + (barDw - fdw2) * 0.5;
const fy0 = by + (barDh - fdh2) * 0.5;
const srcW = fiw * ratHp;
ctx.drawImage(powFillRec.img, 0, 0, srcW, fih, fx0, fy0, fdw2 * ratHp, fdh2);
}
} else {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(bx, by, barDw, barDh);
ctx.fillStyle = 'rgba(255, 80, 200, 0.85)';
ctx.fillRect(bx, by, barDw * ratHp, barDh);
ctx.strokeStyle = 'rgba(180, 255, 255, 0.5)';
ctx.strokeRect(bx, by, barDw, barDh);
}
ctx.restore();
}
/** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */
function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) {
const lx = 0;
const ly = dh * 0.5;
const c = Math.cos(tiltRad);
const s = Math.sin(tiltRad);
return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c };
}
/** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
const n = Math.max(0, Math.min(8, count | 0));
const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
const sz = 1.5;
const tw = 22 * sz * z;
const th = 27 * sz * z;
const stackAnchor = ringRpx + 10 * z;
const tieX = sx;
const tieY = sy - ringRpx * 0.98;
const tiltStep = 0.5;
const spreadX = 5.4 * z;
const baseY = sy - stackAnchor - 1.5 * z;
const items = [];
for (let b = 0; b < n; b++) {
const rel = b - (n - 1) / 2;
const tilt = rel * tiltStep;
const bx = sx + rel * spreadX;
const by = baseY + Math.abs(rel) * 2.1 * z;
const bImg = bRec && bRec.img;
let dw = 0;
let dh = 0;
if (bImg && bImg.complete && bImg.naturalWidth > 0) {
const iw = bImg.naturalWidth;
const ih = bImg.naturalHeight;
const sc = Math.min(tw / iw, th / ih);
dw = iw * sc;
dh = ih * sc;
} else {
const ry = 12 * sz * z;
dw = 10 * sz * z * 2;
dh = ry * 2;
}
const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt);
items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y });
}
if (n > 0) {
ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)';
ctx.lineWidth = Math.max(0.65, 0.78 * z);
ctx.lineCap = 'butt';
ctx.lineJoin = 'bevel';
for (let i = 0; i < items.length; i++) {
const it = items[i];
ctx.beginPath();
ctx.moveTo(tieX, tieY);
ctx.lineTo(it.knotX, it.knotY);
ctx.stroke();
}
}
for (let i = 0; i < items.length; i++) {
const it = items[i];
const bImg = it.bImg;
if (bImg && bImg.complete && bImg.naturalWidth > 0) {
const iw = bImg.naturalWidth;
const ih = bImg.naturalHeight;
const sc = Math.min(tw / iw, th / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.save();
ctx.translate(it.bx, it.by);
ctx.rotate(it.tilt);
ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
} else {
ctx.save();
ctx.translate(it.bx, it.by);
ctx.rotate(it.tilt);
ctx.fillStyle = col;
ctx.beginPath();
ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
}
}
refs.forEach((entry, idx) => {
const ent = entry.ref;
if (ent.balloonBossCx == null || ent.balloonBossCy == null) return;
if (ent.balloonBossEliminated) return;
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
const z = zDraw;
const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6)
? ent.balloonBossSkinSlot
: ((idx % 6) + 1);
const ringR = 25 * z;
/** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
const bubbleCy = sy - ringR * 0.42;
/** มุมลูกศร Artboard 9 — หมุนอัตโนมัติ; Mega Virus ยิงตามมุมนี้ ณ ตอนกด Space */
const angRing = (typeof ent.balloonBossAimRad === 'number' && Number.isFinite(ent.balloonBossAimRad))
? ent.balloonBossAimRad
: ((timeMs * 0.0019 + idx * 1.73) % (Math.PI * 2));
/** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
const faceDir = 'down';
const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim();
const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
/** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */
const ringRec = fbRing
? resolveBalloonBossBalloonSpriteRec('', fbRing)
: resolveBalloonBossBalloonSpriteRec(perRing, '');
ctx.save();
const ringImg = ringRec && ringRec.img;
const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0);
if (ringPainted) {
const iw = ringImg.naturalWidth;
const ih = ringImg.naturalHeight;
const maxBox = 2 * ringR * 1.14;
const sc = Math.min(maxBox / iw, maxBox / ih);
const dw = iw * sc;
const dh = ih * sc;
/** หางฟอง (Artboard +Y) หมุนตาม angRing — tail ชี้ทิศยิง */
ctx.save();
ctx.translate(sx, bubbleCy);
ctx.rotate(angRing - Math.PI / 2);
ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
if (!ringPainted) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)';
ctx.lineWidth = Math.max(1.2, 1.6 * z);
ctx.beginPath();
ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
ctx.lineWidth = Math.max(1, 1.1 * z);
ctx.beginPath();
ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
ctx.stroke();
}
drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR);
const nm = String(ent.nickname || '').slice(0, 12);
ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(248, 250, 252, 0.95)';
ctx.strokeStyle = 'rgba(0, 10, 30, 0.85)';
ctx.lineWidth = 3;
let label = nm;
if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
const labelY = bubbleCy + ringR + 8 * z;
ctx.strokeText(label, sx, labelY);
ctx.fillText(label, sx, labelY);
const charImg = resolvePlayDrawCharSource(
ent.characterId, faceDir, timeMs, !!ent.isWalking,
ent.playTint || resolvePlayTintForPeer(entry.id),
myId != null && entry.id != null && String(entry.id) === String(myId),
);
const size = 26 * z;
if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) {
const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth;
const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
const sc = Math.min(size / iw, size / ih, 1);
const dw = iw * sc, dh = ih * sc;
const charTop = bubbleCy - dh * 0.48;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh);
} else {
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)';
ctx.lineWidth = Math.max(1, 1.2 * z);
ctx.stroke();
}
ctx.restore();
});
const bulletCodeUrl = normalizeGauntletAssetUrlForPlay('/Game/img/MegaVirus/bullet-code.png');
const bulletCodeRec = bulletCodeUrl ? ensureGauntletAssetImage(bulletCodeUrl) : null;
for (let i = 0; i < balloonBossPlayerBullets.length; i++) {
const b = balloonBossPlayerBullets[i];
const [bx, by] = worldToScreen(b.x, b.y);
ctx.save();
ctx.translate(bx, by);
/** bullet-code.png หัวลูกศรชี้ขวา (+x) — หมุนตามทิศความเร็วโดยไม่บวก π/2 (ก่อนหน้านี้ดูเหมือนยิงเฉียง) */
ctx.rotate(Math.atan2(b.vy, b.vx));
if (bulletCodeRec && bulletCodeRec.ready && bulletCodeRec.img && bulletCodeRec.img.naturalWidth > 0) {
const iw = bulletCodeRec.img.naturalWidth;
const ih = bulletCodeRec.img.naturalHeight;
const mh = Math.max(10, 15 * zDraw);
const sc = Math.min(1.4, mh / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.drawImage(bulletCodeRec.img, -dw / 2, -dh / 2, dw, dh);
} else {
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.beginPath();
ctx.moveTo(0, -8 * zDraw);
ctx.lineTo(-5 * zDraw, 6 * zDraw);
ctx.lineTo(5 * zDraw, 6 * zDraw);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(0, 255, 200, 0.5)';
ctx.font = `${Math.max(7, 8 * zDraw)}px monospace`;
ctx.textAlign = 'center';
ctx.fillText('01001', 0, 12 * zDraw);
}
ctx.restore();
}
for (let i = 0; i < balloonBossBossBullets.length; i++) {
const b = balloonBossBossBullets[i];
const [bx, by] = worldToScreen(b.x, b.y);
ctx.fillStyle = 'rgba(255, 60, 120, 0.92)';
ctx.beginPath();
ctx.arc(bx, by, Math.max(3, 4.5 * zDraw), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 200, 255, 0.6)';
ctx.stroke();
}
}
function jumpSurviveCollectPlatformCells(md) {
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
const out = [];
if (!pa) return out;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (pa[y] && pa[y][x] === 1) out.push({ x, y });
}
}
return out;
}
/** ความสูงแมปเป็นคาบวนของแพลตฟอร์ม — แถวที่เลื่อนออกล่างจะวนมาโผล่บน */
function jumpSurvivePlatformPeriodPx(md) {
return (md.height || 15) * tileSize;
}
/**
* ยืนบนหน้าแพลตฟอร์มช่อง (tx,ty) — เลือกชั้น k ที่ใกล้ hintY (กล้อง / กลางแมป)
*/
function jumpSurviveGridPosStandingOnPlatform(md, tx, ty, scrollPx) {
const ts = tileSize;
const h = md.height || 15;
const period = jumpSurvivePlatformPeriodPx(md);
const { cw, ch } = getCharacterFootprintWH(md);
const rawTop = ty * ts + scrollPx;
const hintY = (jumpSurviveCamCenterY > 1)
? jumpSurviveCamCenterY
: h * ts * 0.72;
const k = Math.round((hintY - rawTop - ts * 0.35) / period);
const platTop = rawTop + k * period;
const feetY = platTop;
const pxc = (tx + 0.5) * ts;
return {
x: pxc / ts - cw * 0.5,
y: feetY / ts - ch,
};
}
function jumpSurviveGridPosFromTileCenter(md, tx, ty) {
return jumpSurviveGridPosStandingOnPlatform(md, tx, ty, 0);
}
function jumpSurviveRandomStandGridPos(md) {
const cells = jumpSurviveCollectPlatformCells(md);
const sc = jumpSurvivePlatformScrollPx;
if (cells.length) {
const c = cells[Math.floor(Math.random() * cells.length)];
return jumpSurviveGridPosStandingOnPlatform(md, c.x, c.y, sc);
}
const sp = md.spawn || { x: 1, y: 1 };
return jumpSurviveGridPosStandingOnPlatform(md, Number(sp.x) || 1, Number(sp.y) || 1, sc);
}
function jumpSurviveTileHasPlatformCellPlay(md, tx, ty) {
const pa = md && md.jumpSurvivePlatformArea;
const h = md.height || (pa && pa.length) || 15;
const w = md.width || 20;
if (!pa || ty < 0 || ty >= h) return false;
const row = pa[ty];
if (!row || tx < 0 || tx >= w) return false;
return row[tx] === 1;
}
/** หาแพลตฟอร์มในคอลัมน์ tx ใกล้ ty — กันจุดเกิดช่องว่างแล้วลอย */
function jumpSurviveFindPlatformColumnPlay(md, tx, tyHint) {
const w = md.width || 20, h = md.height || 15;
const tcx = Math.max(0, Math.min(w - 1, Math.floor(tx)));
let ty = Math.max(0, Math.min(h - 1, Math.floor(tyHint)));
if (jumpSurviveTileHasPlatformCellPlay(md, tcx, ty)) return { x: tcx, y: ty };
for (let d = 1; d < h; d++) {
const yb = ty + d;
if (yb < h && jumpSurviveTileHasPlatformCellPlay(md, tcx, yb)) return { x: tcx, y: yb };
const ya = ty - d;
if (ya >= 0 && jumpSurviveTileHasPlatformCellPlay(md, tcx, ya)) return { x: tcx, y: ya };
}
return { x: tcx, y: ty };
}
/** จุดเกิดตามลำดับ join / P1–P6 → world ยืนบนแพลตฟอร์ม (ไม่ทับสุ่ม pool) */
function jumpSurviveSpawnWorldFromJoinOrderPlay(md, joinOrder) {
const sp = pickSpawnForJoinPlay(md, joinOrder | 0);
let tx = Math.floor(Number(sp.x));
let ty = Math.floor(Number(sp.y));
if (!Number.isFinite(tx) || !Number.isFinite(ty)) {
const fb = md.spawn || { x: 1, y: 1 };
tx = Math.floor(Number(fb.x)) || 1;
ty = Math.floor(Number(fb.y)) || 1;
}
const cell = jumpSurviveFindPlatformColumnPlay(md, tx, ty);
const sc = jumpSurvivePlatformScrollPx;
const p = jumpSurviveGridPosStandingOnPlatform(md, cell.x, cell.y, sc);
return {
x: p.x + (Math.random() - 0.5) * 0.05,
y: p.y + (Math.random() - 0.5) * 0.05,
};
}
/** ทดสอบจากเอดิเตอร์: วางคน+บอทบนแพลตฟอร์ม (ไม่ให้เกิดบน spawnArea บนฟ้าแล้วลอย) */
function applyJumpSurvivePreviewSpawnLayout(onlyBots) {
if (!mapData || mapData.gameType !== 'jump_survive') return;
function stampJumpBotState(o) {
if (!o) return;
o.jumpSurviveVy = 0;
o.jumpSurviveOnGround = true;
o.jumpSurviveEliminated = false;
o.tx = o.x;
o.ty = o.y;
}
if (isJumpSurviveMissionUiMapPlay()) {
function snapEnt(ent) {
if (!ent) return;
const ordRaw = Number(ent.spawnJoinOrder);
const jo = Number.isFinite(ordRaw) ? Math.max(0, Math.floor(ordRaw)) : 0;
const pos = jumpSurviveSpawnWorldFromJoinOrderPlay(mapData, jo);
ent.x = pos.x;
ent.y = pos.y;
stampJumpBotState(ent);
}
if (!onlyBots) {
snapEnt(me);
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
realIds.forEach((rid) => snapEnt(others.get(rid)));
}
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
botIds.forEach((bid) => snapEnt(others.get(bid)));
jumpSurviveInitRuntime();
return;
}
const cells = jumpSurviveCollectPlatformCells(mapData);
cells.sort((a, b) => b.y - a.y || a.x - b.x);
const sc = jumpSurvivePlatformScrollPx;
const pool = cells.length
? cells.map((c) => jumpSurviveGridPosStandingOnPlatform(mapData, c.x, c.y, sc))
: [jumpSurviveGridPosStandingOnPlatform(mapData, Number(mapData.spawn && mapData.spawn.x) || 1, Number(mapData.spawn && mapData.spawn.y) || 1, sc)];
const realIds = [...others.keys()].filter((id) => !isPreviewBotId(id)).sort();
const botIds = [...others.keys()].filter(isPreviewBotId).sort();
if (onlyBots) {
let idx = 1 + realIds.length;
botIds.forEach((bid) => {
const o = others.get(bid);
if (!o || !pool.length) return;
const p = pool[idx % pool.length];
idx++;
o.x = p.x + (Math.random() - 0.5) * 0.08;
o.y = p.y + (Math.random() - 0.5) * 0.08;
stampJumpBotState(o);
});
return;
}
let idx = 0;
function assignEnt(ent) {
if (!ent || !pool.length) return;
const p = pool[idx % pool.length];
idx++;
ent.x = p.x + (Math.random() - 0.5) * 0.08;
ent.y = p.y + (Math.random() - 0.5) * 0.08;
stampJumpBotState(ent);
}
assignEnt(me);
realIds.forEach((rid) => assignEnt(others.get(rid)));
botIds.forEach((bid) => assignEnt(others.get(bid)));
jumpSurviveInitRuntime();
}
/**
* ฟิสิกส์กระโดดร่วม (ผู้เล่น + บอท preview)
* ตายจาก: hazard ที่วาง, บีบระหว่าง solid+object wall, หรือตกออกนอกขอบโลกแมป (ไม่ใช้ขอบกล้อง)
* @returns {{ pxc: number, feetY: number, vy: number, onGround: boolean, died: boolean }}
*/
function jumpSurvivePhysicsIntegrate(md, opts) {
const {
dt,
cw, ch,
pxc0, feetY0, vy0, wasOnGround,
jumpQueued,
moveLeft, moveRight,
} = opts;
const w = md.width || 20, h = md.height || 15;
const ts = tileSize;
const bw = ts * 0.34;
const bh = ts * 0.9;
let pxc = pxc0;
let feetY = feetY0;
let vy = vy0;
let onGround = wasOnGround;
const gFull = Number(md.jumpSurviveGravity) > 0 ? Number(md.jumpSurviveGravity) : 3200;
const g = gFull * dt;
let jumpV0;
if (Number(md.jumpSurviveJumpImpulse) > 0) {
jumpV0 = -Number(md.jumpSurviveJumpImpulse);
} else {
const mult = (typeof playJumpSurviveJumpHeightMult === 'number' && playJumpSurviveJumpHeightMult > 0)
? playJumpSurviveJumpHeightMult
: 1.5;
const charHpixels = ch * ts;
const impulse = Math.sqrt(Math.max(1e-6, 2 * gFull * mult * charHpixels));
jumpV0 = -Math.min(impulse, ts * 36);
}
let moveX = (Number(md.jumpSurviveMovePxPerSec) > 0 ? Number(md.jumpSurviveMovePxPerSec) : 200) * dt;
if (isJumpSurviveMissionUiMapPlay()) moveX *= 1.35;
if (jumpQueued && onGround) {
vy = jumpV0;
onGround = false;
}
vy += g;
let nx = pxc;
if (moveLeft) nx -= moveX;
if (moveRight) nx += moveX;
nx = Math.max(bw + 1, Math.min(w * ts - bw - 1, nx));
const headY0 = feetY - bh;
const tryL = nx - bw;
const tryR = nx + bw;
if (!jumpSurviveOverlapsSolidForHorzMove(md, tryL, headY0, tryR, feetY)) {
pxc = nx;
}
feetY += vy * dt;
let headTop = feetY - bh;
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
if (vy > 0) {
const step = ts * 0.035;
const penetrationEst = Math.abs(vy) * dt + ts * 0.55;
const maxSteps = Math.min(160, Math.max(28, Math.ceil(penetrationEst / step) + 6));
for (let s = 0; s < maxSteps; s++) {
feetY -= step;
headTop = feetY - bh;
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
vy = 0;
onGround = true;
break;
}
}
} else if (vy < 0) {
const stepUp = ts * 0.035;
const penetrationEstUp = Math.abs(vy) * dt + ts * 0.55;
const maxStepsUp = Math.min(160, Math.max(28, Math.ceil(penetrationEstUp / stepUp) + 6));
for (let s = 0; s < maxStepsUp; s++) {
feetY += stepUp;
headTop = feetY - bh;
if (!jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY)) {
vy = 0;
break;
}
}
}
} else {
onGround = false;
}
headTop = feetY - bh;
const headBandBottom = headTop + bh * 0.42;
if (jumpSurviveOverlapsSolid(md, pxc - bw, headTop, pxc + bw, feetY) &&
jumpSurviveOverlapsObjectWall(md, pxc - bw, headTop, pxc + bw, headBandBottom)) {
return { pxc, feetY, vy, onGround, died: true };
}
headTop = feetY - bh;
if (jumpSurviveOverlapsHazardArea(md, pxc - bw, headTop, pxc + bw, feetY)) {
return { pxc, feetY, vy, onGround, died: true };
}
headTop = feetY - bh;
/* ไม่ใช้ขอบกล้อง (cam topKill/botKill) — ตายเฉพาะ hazard ที่วาง + ตกออกนอกโลกแมปจริง (ไม่ตายในอากาศระหว่างแพลตฟอร์ม) */
const mapHpx = h * ts;
if (feetY > mapHpx + ts * 8 || headTop < -ts * 32) {
return { pxc, feetY, vy, onGround, died: true };
}
return { pxc, feetY, vy, onGround, died: false };
}
function jumpSurviveEntityFromHitbox(o, md, pxc, feetYpx) {
const { cw, ch } = getCharacterFootprintWH(md);
o.x = pxc / tileSize - cw * 0.5;
o.y = feetYpx / tileSize - ch;
}
function jumpSurviveInitRuntime() {
jumpSurviveLastTickMs = performance.now();
jumpSurviveSessionStartMs = performance.now();
jumpSurviveVy = 0;
jumpSurviveJumpQueued = false;
jumpSurviveOnGround = false;
jumpSurvivePlatformScrollPx = 0;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ts = tileSize;
if (isJumpSurviveMissionUiMapPlay()) {
const w = mapData.width || 20, h = mapData.height || 15;
jumpSurviveCamCenterX = (w * ts) * 0.5;
jumpSurviveCamCenterY = (h * ts) * 0.5;
} else {
jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts;
jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts;
}
}
function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts);
const x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts);
const y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w) return true;
if (ty < 0) continue;
/* แพลตฟอร์มวนแนวตั้ง (period = h*ts) — worldY เกิน h*ts เป็นปกติ ห้ามถือ ty>=h เป็น “ซีลพื้นแมป” ไม่งั้น solid+กำแพงข้าง = ตายคาแอร์ */
if (ty >= h) continue;
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
if (!pa) return false;
const period = jumpSurvivePlatformPeriodPx(md);
if (period < ts) return false;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const vm = ts * 2;
for (let ty = 0; ty < h; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (!pa[ty] || pa[ty][tx] !== 1) continue;
const rawTop = ty * ts + scrollPx;
let kMin = Math.ceil((top - rawTop - ts - vm) / period);
let kMax = Math.floor((bottom - rawTop + vm) / period);
/* ceil/floor กับช่วงสั้น ๆ อาจได้ kMin > kMax → ไม่ลอง k เลย แล้วตกทะลุแพลตฟอร์ม */
if (kMin > kMax) {
const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
kMin = kGuess;
kMax = kGuess;
}
for (let k = kMin; k <= kMax; k++) {
const platTop = rawTop + k * period;
const platBot = platTop + ts;
const tileLeft = tx * ts;
const tileRight = (tx + 1) * ts;
if (platBot > top && platTop < bottom && right > tileLeft && left < tileRight) return true;
}
}
}
return false;
}
/** แถวแพลตฟอร์มบนสุด/ล่างสุดของแมป (มีช่องอย่างน้อยหนึ่งช่อง) — ใช้เป็นเพดาน/พื้นตาย */
function jumpSurvivePlatformEdgeKillRows(md) {
const pa = md && md.jumpSurvivePlatformArea;
if (!pa) return null;
const h = Math.min(pa.length, md.height || pa.length || 15);
const wM = md.width || 20;
let minTy = h;
let maxTy = -1;
for (let ty = 0; ty < h; ty++) {
const row = pa[ty];
if (!row) continue;
const wlim = Math.min(row.length || 0, wM);
let rowHas = false;
for (let tx = 0; tx < wlim; tx++) {
if (row[tx] === 1) {
rowHas = true;
break;
}
}
if (!rowHas) continue;
if (ty < minTy) minTy = ty;
if (ty > maxTy) maxTy = ty;
}
if (minTy > maxTy) return null;
if (minTy === maxTy) return null;
return { minTy, maxTy };
}
/** ชนแพลตฟอร์มแถวเพดานเท่านั้น (รวมเลื่อนแบบคาบ) = ตายทันที — ไม่ฆ่าที่แถวพื้นล่าง */
function jumpSurviveOverlapsEdgeKillPlatforms(md, left, top, right, bottom, scrollPx) {
const edges = jumpSurvivePlatformEdgeKillRows(md);
if (!edges) return false;
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const pa = md.jumpSurvivePlatformArea;
if (!pa) return false;
const period = jumpSurvivePlatformPeriodPx(md);
if (period < ts) return false;
const x0 = Math.max(0, Math.floor(left / ts));
const x1 = Math.min(w - 1, Math.floor((right - 1e-6) / ts));
const vm = ts * 2;
const overlapsRowTy = function (ty) {
if (ty < 0 || ty >= h || !pa[ty]) return false;
for (let tx = x0; tx <= x1; tx++) {
if (pa[ty][tx] !== 1) continue;
const rawTop = ty * ts + scrollPx;
let kMin = Math.ceil((top - rawTop - ts - vm) / period);
let kMax = Math.floor((bottom - rawTop + vm) / period);
if (kMin > kMax) {
const kGuess = Math.round((bottom - rawTop - ts * 0.5) / period);
kMin = kGuess;
kMax = kGuess;
}
const zSlack = ts * 0.08;
for (let k = kMin; k <= kMax; k++) {
const platTop = rawTop + k * period;
const platBot = platTop + ts;
const tileLeft = tx * ts;
const tileRight = (tx + 1) * ts;
if (platBot > top - zSlack && platTop < bottom + zSlack && right > tileLeft && left < tileRight) return true;
}
}
return false;
};
if (overlapsRowTy(edges.minTy)) return true;
return false;
}
function jumpSurviveOverlapsSolid(md, left, top, right, bottom) {
if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, jumpSurvivePlatformScrollPx);
}
/**
* ชนแนวนอน: objects เต็มกล่อง; แพลตฟอร์มไม่นับช่วงปลายเท้าเล็กน้อย
* (ถ้าใช้กล่องเต็มกับแพลตฟอร์ม ขณะยืนบนแพลตฟอร์มมักทับช่องแนวตั้ง — เดินซ้ายขวาถูกปฏิเสธทั้งก้อน)
*/
function jumpSurviveOverlapsSolidForHorzMove(md, left, top, right, bottom) {
if (jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom)) return true;
const ts = tileSize;
const footSlop = ts * 0.16;
const platformBottom = bottom - footSlop;
if (platformBottom <= top + ts * 0.05) return false;
return jumpSurviveOverlapsPlatforms(md, left, top, right, platformBottom, jumpSurvivePlatformScrollPx);
}
/** Head/body overlaps กำแพง (objects=1) เท่านั้น — ใช้ตรวจชนฝาบนแล้วตาย */
function jumpSurviveOverlapsObjectWall(md, left, top, right, bottom) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
const x0 = Math.floor(left / ts);
const x1 = Math.floor((right - 1e-6) / ts);
const y0 = Math.floor(top / ts);
const y1 = Math.floor((bottom - 1e-6) / ts);
for (let ty = y0; ty <= y1; ty++) {
for (let tx = x0; tx <= x1; tx++) {
if (tx < 0 || tx >= w || ty < 0 || ty >= h) continue;
const ob = md.objects && md.objects[ty] && md.objects[ty][tx];
if (ob === 1) return true;
}
}
return false;
}
function jumpSurviveSyncMeFromHitbox(pxc, feetYpx) {
jumpSurviveEntityFromHitbox(me, mapData, pxc, feetYpx);
}
function jumpSurviveCameraTargetPx(md) {
const ts = tileSize;
const w = md.width || 20, h = md.height || 15;
if (isJumpSurviveMissionUiMapPlay()) {
return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 };
}
const { cw, ch } = getCharacterFootprintWH(md);
const cx = (e) => (e.x + cw * 0.5) * ts;
const cy = (e) => (e.y + ch * 0.5) * ts;
if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) };
const aliveHumanIds = [...others.keys()].filter((id) => {
if (isPreviewBotId(id)) return false;
const o = others.get(id);
return o && !o.jumpSurviveEliminated;
});
const aliveBotIds = [...others.keys()].filter((id) => {
if (!isPreviewBotId(id)) return false;
const o = others.get(id);
return o && !o.jumpSurviveEliminated;
});
const useIds = aliveHumanIds.length ? aliveHumanIds : aliveBotIds;
if (!useIds.length) return { px: cx(me), py: cy(me) };
let sx = 0, sy = 0;
useIds.forEach((id) => {
const o = others.get(id);
if (!o) return;
sx += cx(o);
sy += cy(o);
});
const n = useIds.length;
return { px: sx / n, py: sy / n };
}
/** มีแพลตฟอร์มใต้จุดเล็กน้อยข้างหน้าแนว wishDir หรือไม่ — กันบอทเดินหลุมแล้วตกจอ */
function jumpSurviveBotHasFloorAhead(md, pxc, feetY, wishDir, scrollPx) {
if (!wishDir) return true;
const ts = tileSize;
const bw = ts * 0.34;
const probeX = pxc + wishDir * (bw + ts * 0.82);
const pad = ts * 0.24;
const left = probeX - pad;
const right = probeX + pad;
const top = feetY - ts * 0.45;
const bottom = feetY + ts * 8;
return jumpSurviveOverlapsPlatforms(md, left, top, right, bottom, scrollPx);
}
function stepJumpSurvivePreviewBots(dt, halfViewH) {
if (!playBotsEnabled() || !mapData || !isJumpSurvive()) return;
const md = mapData;
const { cw, ch } = getCharacterFootprintWH(md);
const ts = tileSize;
const wpx = (md.width || 20) * ts;
const centerPx = wpx * 0.5;
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
if (o.jumpSurviveEliminated) return;
if (typeof o.jumpSurviveVy !== 'number') o.jumpSurviveVy = 0;
if (o.jumpSurviveOnGround == null) o.jumpSurviveOnGround = false;
if (typeof o.jumpSurviveNextJumpAi !== 'number') o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * 500);
if (typeof o.jumpSurviveWishNext !== 'number') o.jumpSurviveWishNext = Date.now() + 200;
if (o.jumpSurviveWishX == null) o.jumpSurviveWishX = 0;
let pxc = (o.x + cw * 0.5) * ts;
let feetY = (o.y + ch) * ts;
const camCy = jumpSurviveCamCenterY;
const mission = isJumpSurviveMissionUiMapPlay();
const lowDanger = feetY > camCy + halfViewH - ts * 8.5;
if (Date.now() >= o.jumpSurviveWishNext) {
const wMin = mission && lowDanger ? 180 : 380;
const wSpan = mission && lowDanger ? 480 : 700;
o.jumpSurviveWishNext = Date.now() + wMin + Math.floor(Math.random() * wSpan);
const tier = o.botTier || 'avg';
if (mission && lowDanger && Math.random() < 0.55) {
o.jumpSurviveWishX = 0;
} else if (pxc < centerPx - ts * 1.8) o.jumpSurviveWishX = 1;
else if (pxc > centerPx + ts * 1.8) o.jumpSurviveWishX = -1;
else if (tier === 'sharp') {
const roam = mission ? 0.42 : 0.55;
o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
} else {
const roam = mission ? 0.28 : 0.4;
o.jumpSurviveWishX = Math.random() < roam ? (Math.random() < 0.5 ? -1 : 1) : 0;
}
}
let moveLeft = o.jumpSurviveWishX < 0;
let moveRight = o.jumpSurviveWishX > 0;
let hazardJump = false;
if (o.jumpSurviveOnGround) {
const sc = jumpSurvivePlatformScrollPx;
if (moveRight && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, 1, sc)) {
o.jumpSurviveWishX = Math.random() < 0.65 ? -1 : 0;
hazardJump = true;
} else if (moveLeft && !jumpSurviveBotHasFloorAhead(md, pxc, feetY, -1, sc)) {
o.jumpSurviveWishX = Math.random() < 0.65 ? 1 : 0;
hazardJump = true;
}
moveLeft = o.jumpSurviveWishX < 0;
moveRight = o.jumpSurviveWishX > 0;
}
let jumpQ = false;
if (hazardJump) o.jumpSurviveNextJumpAi = Math.min(o.jumpSurviveNextJumpAi, Date.now());
if (o.jumpSurviveOnGround && Date.now() >= o.jumpSurviveNextJumpAi) {
o.jumpSurviveNextJumpAi = Date.now() + 300 + Math.floor(Math.random() * (hazardJump ? 520 : 1100));
const tier = o.botTier || 'avg';
let jp = tier === 'sharp' ? 0.5 : tier === 'weak' ? 0.25 : 0.36;
if (mission) jp += 0.12;
if (lowDanger) jp += 0.22;
jp = Math.min(0.9, jp);
if (hazardJump || Math.random() < jp) jumpQ = true;
}
const r = jumpSurvivePhysicsIntegrate(md, {
dt,
cw,
ch,
pxc0: pxc,
feetY0: feetY,
vy0: o.jumpSurviveVy,
wasOnGround: o.jumpSurviveOnGround,
jumpQueued: jumpQ,
moveLeft,
moveRight,
});
if (r.died) {
o.jumpSurviveEliminated = true;
o.jumpSurviveVy = 0;
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
o.tx = o.x;
o.ty = o.y;
o.botIsWalking = false;
return;
}
o.jumpSurviveVy = r.vy;
o.jumpSurviveOnGround = r.onGround;
jumpSurviveEntityFromHitbox(o, md, r.pxc, r.feetY);
o.tx = o.x;
o.ty = o.y;
if (moveRight) o.direction = 'right';
else if (moveLeft) o.direction = 'left';
o.botIsWalking = Math.abs(r.vy) > 40 || moveLeft || moveRight;
});
}
function jumpSurviveTickFrame() {
if (!mapData || !isJumpSurvive()) return;
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return;
const remEnd = jumpSurviveRemainingSecMission();
if (remEnd !== null && remEnd <= 0) {
endJumpSurviveMissionRound('time_up');
return;
}
}
const md = mapData;
const ts = tileSize;
const now = performance.now();
const dt = Math.min(0.05, Math.max(0, (now - jumpSurviveLastTickMs) / 1000));
jumpSurviveLastTickMs = now;
let zJumpSurviveWorld = zoom;
if (previewMode && editorEmbedReturn && mapData) zJumpSurviveWorld *= playEmbedUserZoomMul;
const halfViewH = canvas.height / (2 * zJumpSurviveWorld);
const rise = (Number(md.jumpSurviveRisePxPerSec) > 0 ? Number(md.jumpSurviveRisePxPerSec) : 42) * dt;
jumpSurvivePlatformScrollPx += rise;
const camTgt = jumpSurviveCameraTargetPx(md);
const ck = Math.min(1, dt * 7);
jumpSurviveCamCenterX += (camTgt.px - jumpSurviveCamCenterX) * ck;
jumpSurviveCamCenterY += (camTgt.py - jumpSurviveCamCenterY) * ck;
if (jumpSurviveEliminated) {
stepJumpSurvivePreviewBots(dt, halfViewH);
jumpSurviveMissionMaybeEarlyFinish();
return;
}
const { cw, ch } = getCharacterFootprintWH(md);
let pxc = (me.x + cw * 0.5) * ts;
let feetY = (me.y + ch) * ts;
const jq = jumpSurviveJumpQueued;
jumpSurviveJumpQueued = false;
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']),
});
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;
playEmbedUserZoomMul = 1;
if (mapData.gameType !== 'quiz_carry') {
quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT;
}
{
const qMap = (playMapIdFromQuery || '').trim();
const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : '';
const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : '';
if (qMap) playSessionMapId = qMap;
else if (dataId) playSessionMapId = dataId;
else if (resMap) playSessionMapId = resMap;
}
applyPlayEmbedZoomForCurrentMapPlay();
if (res && res.hostId != null) playHostId = res.hostId;
if (!isQuizQuestionMissionUiMapPlay()) {
quizQuestionMissionPhase = null;
quizQuestionMissionDeferredPhase = null;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
}
if (mapData.gameType !== 'space_shooter') {
spaceShooterGameEnded = false;
if (prevWasSpaceShooter) {
const gend = document.getElementById('gauntlet-ended-overlay');
if (gend) gend.classList.add('is-hidden');
spaceShooterMissionPhase = null;
spaceShooterAsteroidExplosions = [];
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
const gccLeave = document.getElementById('gauntlet-crown-countdown');
if (gccLeave) gccLeave.classList.add('is-hidden');
}
}
if (mapData.gameType !== 'balloon_boss') {
balloonBossGameEnded = false;
if (prevWasBalloonBoss) {
const gend = document.getElementById('gauntlet-ended-overlay');
if (gend) gend.classList.add('is-hidden');
}
}
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
if (!mapData.interactive) mapData.interactive = [];
if (mapData.gameType === 'quiz') {
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
}
if (mapData.gameType === 'stack') {
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
if (!mapData.stackLandArea) mapData.stackLandArea = [];
}
if (mapData.gameType === 'quiz_carry') {
if (!mapData.quizCarryHubArea) mapData.quizCarryHubArea = [];
if (!mapData.quizCarryOptionArea) mapData.quizCarryOptionArea = [];
if (!mapData.carryEmbedCountdownArea) mapData.carryEmbedCountdownArea = [];
if (!mapData.quizQuestions) mapData.quizQuestions = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
normalizeQuizCarryLayersInPlay(mapData);
applyQuizCarryWalkSpeedFromSettingsObj(quizCarryJoinSettingsSnap || {});
}
if (mapData.gameType === 'quiz_battle') {
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
normalizeQuizBattlePathInPlay(mapData);
normalizeQuizBattleDomeInPlay(mapData);
}
tileSize = mapData.tileSize || 32;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
reloadPlayScrollBgFromMap();
reloadStackTowerScrollBgFromMap();
reloadGauntletCrownRunwayBgFromMap();
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
const plist = Array.isArray(res.peers) ? res.peers : [];
const myPeer = plist.find(p => p.id === myId);
const myPos = peerXYFromJoin(myPeer, mapData.spawn);
me.x = myPos.x;
me.y = myPos.y;
if (isQuizCarry()) {
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(me.x, me.y);
me.x = hubSnap.x;
me.y = hubSnap.y;
}
if (isQuizBattle()) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = pathSnap.x;
me.y = pathSnap.y;
}
me.direction = myPeer?.direction ?? 'down';
if (myPeer && myPeer.nickname) {
me.nickname = String(myPeer.nickname).trim() || me.nickname;
nick = me.nickname;
}
me.characterId = myPeer?.characterId || null;
meGauntletJumpTicks = typeof myPeer?.gauntletJumpTicks === 'number' ? myPeer.gauntletJumpTicks : 0;
meGauntletJumpVis = meGauntletJumpTicks;
{
const sc0 = Number(myPeer && myPeer.gauntletScore);
me.gauntletScore = Number.isFinite(sc0) ? Math.max(0, sc0) : 0;
me.gauntletEliminated = !!(myPeer && myPeer.gauntletEliminated);
}
{
const sh0 = Number(myPeer && myPeer.spaceShooterScore);
me.spaceShooterScore = Number.isFinite(sh0) ? Math.max(0, sh0) : 0;
}
{
const bSt = balloonBossBalloonsStartPlay();
const bs0 = Number(myPeer && myPeer.balloonBossScore);
me.balloonBossScore = Number.isFinite(bs0) ? Math.max(0, bs0) : 0;
const bd0 = Number(myPeer && myPeer.balloonBossBossDmg);
me.balloonBossBossDmg = Number.isFinite(bd0) ? Math.max(0, bd0) : 0;
const bb0 = Number(myPeer && myPeer.balloonBossBalloons);
me.balloonBossBalloons = Number.isFinite(bb0) ? Math.max(0, Math.floor(bb0)) : bSt;
me.balloonBossEliminated = !!(myPeer && myPeer.balloonBossEliminated);
}
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
me.playTint = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')));
// อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม
try {
preloadPlayCharacterLayers(me.characterId || getPlayCharacterId());
(Array.isArray(plist) ? plist : []).forEach(function (p) { if (p && p.characterId) preloadPlayCharacterLayers(p.characterId); });
preloadPlayOccupantsTints();
preloadPlayLobbyIdleAvatarsForOccupants();
} catch (e) { /* ignore */ }
{
const jo = Number(myPeer && myPeer.spawnJoinOrder);
me.spawnJoinOrder = Number.isFinite(jo) ? Math.max(0, Math.floor(jo)) : Math.max(0, plist.findIndex((q) => q && q.id === myId));
}
others.clear();
plist.forEach(p => {
if (p.id !== myId) {
const pos = peerXYFromJoin(p, mapData.spawn);
let px = pos.x, py = pos.y;
if (isQuizCarry()) {
const hubSnap = snapPositionOutOfQuizCarryHubIfNeeded(px, py);
px = hubSnap.x;
py = hubSnap.y;
}
if (isQuizBattle()) {
const pathSnap = snapPositionOntoQuizBattlePathIfNeeded(px, py);
px = pathSnap.x;
py = pathSnap.y;
}
const joP = Number(p && p.spawnJoinOrder);
const spawnOrd = Number.isFinite(joP) ? Math.max(0, Math.floor(joP)) : Math.max(0, plist.findIndex((q) => q && q.id === p.id));
others.set(p.id, {
x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId,
spawnJoinOrder: spawnOrd,
playTint: resolvePlayTintForPeer(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; })(),
balloonBossBossDmg: (() => { const s = Number(p.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossBalloons: (() => {
const s = Number(p.balloonBossBalloons);
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
})(),
balloonBossEliminated: !!p.balloonBossEliminated,
quizCarryHeld: null,
});
}
});
if (mapData.gameType === 'gauntlet') {
me.tx = me.x;
me.ty = me.y;
if (res.gauntletEndsAt != null) {
const geJoin = Number(res.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(geJoin) ? geJoin : null;
} else {
gauntletEndsAtMs = null;
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {});
} else {
me.tx = null;
me.ty = null;
gauntletEndsAtMs = null;
}
rebalancePreviewBots();
if (mapData.gameType === 'space_shooter') {
applySpaceShooterSpawnLayoutPlay();
}
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
if (isStack()) {
lastStackTickMs = performance.now();
resetStackMinigameState();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
else reapplyStackMiniSizingFromGlobals();
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
});
if (isStackTowerMissionUiMapPlay()) {
stackTowerMissionEndedOnce = false;
stackTowerMissionDeferredPhase = null;
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
stackTowerMissionPhase = 'howto';
stackTowerSessionStartAt = 0;
hideConflictingOverlaysForGauntletCrown();
applyStackTowerMissionPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 50);
} else {
teardownStackTowerMissionUiPlay();
}
} else {
stackMini = null;
teardownStackTowerMissionUiPlay();
}
playPath = [];
me.isWalking = false;
if (mapData.gameType === 'jump_survive') {
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
normalizeJumpSurvivePlatformAreaInPlay(mapData);
normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
normalizeJumpSurviveHazardAreaInPlay(mapData);
normalizeShooterSpawnSlotsInPlay(mapData);
jumpSurviveEliminated = false;
jumpSurviveGameEnded = false;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
if (isJumpSurviveMissionUiMapPlay()) {
jumpSurviveMissionPhase = 'howto';
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50);
} else {
jumpSurviveMissionPhase = null;
jumpSurviveInitRuntime();
}
}
if (mapData.gameType === 'space_shooter') {
normalizeShooterSpawnSlotsInPlay(mapData);
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
if (isSpaceShooterMissionUiMapPlay()) {
spaceShooterMissionPhase = 'howto';
hideConflictingOverlaysForGauntletCrown();
applySpaceShooterMissionPanelImages();
setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 50);
} else {
spaceShooterMissionPhase = null;
spaceShooterGameEnded = false;
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterAsteroidExplosions = [];
spaceShooterPopups = [];
spaceShooterLastTickMs = performance.now();
spaceShooterSpawnAccMs = 0;
spaceShooterFireCd = 0;
spaceShooterSessionStartMs = performance.now();
spaceShooterLastMoveEmit = 0;
}
}
if (mapData.gameType === 'balloon_boss') {
balloonBossGameEnded = false;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossHitFx = [];
balloonBossScorePopups = [];
balloonBossLastTickMs = performance.now();
balloonBossPlayerFireCd = 0;
balloonBossSessionStartMs = performance.now();
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
}
if (isQuiz()) {
setupPlayQuizUi();
if (isQuizQuestionMissionUiMapPlay()) {
quizQuestionMissionDeferredPhase = null;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
quizQuestionMissionPhase = 'howto';
hideConflictingOverlaysForGauntletCrown();
applyQuizQuestionMissionPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (t) { if (t) applyGauntletTimingFromServer(t); })
.catch(function () {});
setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 50);
}
} else if (isQuizCarry()) setupPlayQuizCarryUi();
else if (isQuizBattle()) setupPlayQuizBattleUi();
else teardownPlayQuizUi();
if (mapData.gameType === 'gauntlet' && isGauntletCrownHeistMapPlay()) {
hideConflictingOverlaysForGauntletCrown();
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccR = document.getElementById('gauntlet-crown-countdown');
if (gccR) gccR.classList.add('is-hidden');
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
} else if (mapData.gameType === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) {
hideConflictingOverlaysForGauntletCrown();
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccRm = document.getElementById('gauntlet-crown-countdown');
if (gccRm) gccRm.classList.add('is-hidden');
if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) {
applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld });
}
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50);
} else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) {
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccRj = document.getElementById('gauntlet-crown-countdown');
if (gccRj) gccRj.classList.add('is-hidden');
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
} else if (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) {
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
const gccRs = document.getElementById('gauntlet-crown-countdown');
if (gccRs) gccRs.classList.add('is-hidden');
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
} else if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
const gccRq = document.getElementById('gauntlet-crown-countdown');
if (gccRq) gccRq.classList.add('is-hidden');
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
} else if (isStack() && isStackTowerMissionUiMapPlay()) {
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
const gccRst = document.getElementById('gauntlet-crown-countdown');
if (gccRst) gccRst.classList.add('is-hidden');
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
} else {
const hov = document.getElementById('gauntlet-crown-howto-overlay');
if (hov) {
hov.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
}
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccR2 = document.getElementById('gauntlet-crown-countdown');
if (gccR2) gccR2.classList.add('is-hidden');
teardownStackTowerMissionUiPlay();
}
resizeCanvas(); draw(); tick();
}
socket.on('connect', () => {
firstCharacterDefaultPromise.then(() => {
const joinPlayMapId = (params.get('map') || '').trim();
const joinNick = (typeof window.jdReadDisplayName === 'function' ? window.jdReadDisplayName() : null)
|| readStoredDisplayName()
|| nick;
socket.emit('join-space', {
spaceId,
nickname: joinNick,
characterId: getPlayCharacterId(),
playMapId: joinPlayMapId || undefined,
}, (res) => {
if (!res || !res.ok) {
const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้';
alert(errMsg);
const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg);
if (caseLocked) {
window.location.replace(buildRoomLobbyReturnHref());
} else {
window.location.replace(playDesignMainMenuHref());
}
return;
}
myId = socket.id;
let botSlots = parseInt(res.botSlotCount, 10);
if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) {
botSlots = 6 - res.maxPlayers;
}
if (botSlots > 0) {
detectiveCaseFillBots = true;
detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots));
}
if (isDetectiveMinigamePlay()) {
try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ }
}
if (res.quizSettingsSnap && typeof res.quizSettingsSnap === 'object') {
if (res.quizSettingsSnap.quizMapPanelTheme && typeof res.quizSettingsSnap.quizMapPanelTheme === 'object') {
setQuizMapPanelThemeFromApi({ quizMapPanelTheme: res.quizSettingsSnap.quizMapPanelTheme });
}
}
if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') {
quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap;
if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') {
setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme });
}
if (res.quizCarrySettingsSnap.carryEmbedCountdownTheme && typeof res.quizCarrySettingsSnap.carryEmbedCountdownTheme === 'object') {
setQuizCarryEmbedCountdownThemeFromApi({ carryEmbedCountdownTheme: res.quizCarrySettingsSnap.carryEmbedCountdownTheme });
}
if (Array.isArray(res.quizCarrySettingsSnap.carryChoicePlaqueThemes) && res.quizCarrySettingsSnap.carryChoicePlaqueThemes.length) {
setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueThemes: res.quizCarrySettingsSnap.carryChoicePlaqueThemes });
} else if (res.quizCarrySettingsSnap.carryChoicePlaqueTheme && typeof res.quizCarrySettingsSnap.carryChoicePlaqueTheme === 'object') {
setQuizCarryChoicePlaqueThemeFromApi({ carryChoicePlaqueTheme: res.quizCarrySettingsSnap.carryChoicePlaqueTheme });
}
const joinSc = Number(res.quizCarrySettingsSnap.carryChoicePlaqueMapScale);
if (Number.isFinite(joinSc)) {
quizCarryPlaqueMapScale = Math.max(0.85, Math.min(2.5, joinSc));
}
}
if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') {
const q = [];
q.push('space=' + encodeURIComponent(spaceId));
q.push('nick=' + encodeURIComponent(nick));
const mid = params.get('map');
if (mid) q.push('map=' + encodeURIComponent(mid));
q.push('preview=1');
q.push('editorEmbed=1');
if (params.get('defaultChar') != null) q.push('defaultChar=' + encodeURIComponent(params.get('defaultChar')));
window.location.replace(BASE + '/room-lobby.html?' + q.join('&'));
return;
}
const playMapId = params.get('map');
if (playMapId) {
fetch(BASE + '/api/maps/' + encodeURIComponent(playMapId))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) applyMapAndStart(data, res);
else applyMapAndStart(res.mapData, res);
})
.catch(() => applyMapAndStart(res.mapData, res));
} else {
applyMapAndStart(res.mapData, res);
}
});
});
});
const LERP = 0.2;
function applyPlayDisplayNameUpdate(data) {
if (!data || !data.id) return;
const next = String(data.nickname || '').trim();
if (!next) return;
if (data.id === myId) {
me.nickname = next;
nick = next;
try {
localStorage.setItem('roomLobbyDisplayName', next);
localStorage.setItem('playerName', next);
} catch (e) { /* ignore */ }
if (window.jdDisplayName && typeof window.jdDisplayName.syncDom === 'function') {
window.jdDisplayName.syncDom(next);
}
} else {
const o = others.get(data.id);
if (o) o.nickname = next;
}
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (typeof syncPlayCyberHud === 'function') syncPlayCyberHud();
}
socket.on('peer-display-name', applyPlayDisplayNameUpdate);
window.addEventListener('jd-display-name-changed', function (ev) {
const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : '';
if (!next || !myId) return;
applyPlayDisplayNameUpdate({ id: myId, nickname: next });
});
socket.on('user-joined', (data) => {
if (!data || isPreviewBotId(data.id)) return;
const x = Number(data.x);
const y = Number(data.y);
const px = Number.isFinite(x) ? x : 1;
const py = Number.isFinite(y) ? y : 1;
const joJ = Number(data && data.spawnJoinOrder);
const spawnOrdJ = Number.isFinite(joJ) ? Math.max(0, Math.floor(joJ)) : 0;
others.set(data.id, {
x: px, y: py, tx: px, ty: py,
direction: data.direction || 'down', nickname: data.nickname,
characterId: data.characterId ?? null,
spawnJoinOrder: spawnOrdJ,
playTint: resolvePlayTintForPeer(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; })(),
balloonBossBossDmg: (() => { const s = Number(data.balloonBossBossDmg); return Number.isFinite(s) ? Math.max(0, s) : 0; })(),
balloonBossBalloons: (() => {
const s = Number(data.balloonBossBalloons);
return Number.isFinite(s) ? Math.max(0, Math.floor(s)) : balloonBossBalloonsStartPlay();
})(),
balloonBossEliminated: !!data.balloonBossEliminated,
quizCarryHeld: null,
});
if (playBotsEnabled()) rebalancePreviewBots();
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
});
socket.on('host-changed', (d) => {
if (d && d.hostId != null) playHostId = d.hostId;
if (quizCarryPregameActive && isQuizCarry()) updateQuizCarryPregameHud();
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
});
socket.on('quiz-carry-lobby-sync', (d) => {
if (!d || typeof d.readyMap !== 'object') return;
quizCarryLobbyReadyMap = { ...d.readyMap };
if (quizCarryPregameActive && isQuizCarry()) {
quizCarrySyncGuestReadyIfNeeded();
updateQuizCarryPregameHud();
}
});
socket.on('quiz-carry-lobby-started', () => {
if (!quizCarryEmbedMissionFlowActive()) return;
endQuizCarryEmbedPregameAndStart();
});
socket.on('gauntlet-crown-lobby-sync', (d) => {
if (!d || typeof d.readyMap !== 'object') return;
gauntletCrownLobbyReadyMap = { ...d.readyMap };
if (gauntletCrownPregamePhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateGauntletCrownHowtoHud();
} else if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateQuizQuestionMissionHowtoHud();
} else if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateStackTowerMissionHowtoHud();
} else if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateJumpSurviveMissionHowtoHud();
} else if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
gauntletCrownSyncGuestReadyIfNeeded();
updateSpaceShooterMissionHowtoHud();
}
});
socket.on('gauntlet-crown-lobby-started', () => {
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') {
beginQuizQuestionMissionCountdownThenRun();
return;
}
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') {
beginStackTowerMissionCountdownThenRun();
return;
}
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') {
beginJumpSurviveMissionCountdownThenRun();
return;
}
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') {
beginSpaceShooterMissionCountdownThenRun();
return;
}
if (!usesCrownLobbyShellPlay()) return;
beginGauntletCrownCountdownThenRun();
});
socket.on('user-move', (data) => {
if (data && myId != null && data.id === myId) {
/* Gauntlet: ตำแหน่งอ้างอิงจาก gauntlet-sync + เลอร์ปเท่านั้น — ไม่งั้น echo จาก move จะสแนป me.x/y โดยไม่อัปเดต tx/ty ทำให้เลอร์ปผิดและชนเซิร์ฟเวอร์ไม่ตรง */
if (mapData && mapData.gameType === 'gauntlet') {
return;
}
if (mapData && mapData.gameType === 'stack') {
return;
}
if (mapData && mapData.gameType === 'jump_survive') {
return;
}
if (mapData && mapData.gameType === 'space_shooter') {
if (data.spaceShooterScore != null) {
const s = Number(data.spaceShooterScore);
if (Number.isFinite(s)) me.spaceShooterScore = Math.max(0, s);
}
return;
}
if (mapData && mapData.gameType === 'balloon_boss') {
if (data.balloonBossScore != null) {
const s = Number(data.balloonBossScore);
if (Number.isFinite(s)) me.balloonBossScore = Math.max(0, s);
}
if (data.balloonBossBossDmg != null) {
const d = Number(data.balloonBossBossDmg);
if (Number.isFinite(d)) me.balloonBossBossDmg = Math.max(0, d);
}
if (data.balloonBossBalloons != null) {
const b = Number(data.balloonBossBalloons);
if (Number.isFinite(b)) me.balloonBossBalloons = Math.max(0, Math.floor(b));
}
if (data.balloonBossEliminated != null) me.balloonBossEliminated = !!data.balloonBossEliminated;
return;
}
if (mapData && mapData.gameType === 'quiz_battle') {
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) me.x = x;
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) me.y = y;
}
const s = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = s.x;
me.y = s.y;
if (data.direction) me.direction = data.direction;
if (data.characterId != null) me.characterId = data.characterId;
playPath = [];
return;
}
if (data.x != null) {
const x = Number(data.x);
if (Number.isFinite(x)) {
me.x = x;
me.tx = me.x;
}
}
if (data.y != null) {
const y = Number(data.y);
if (Number.isFinite(y)) {
me.y = y;
me.ty = me.y;
}
}
if (data.direction) me.direction = data.direction;
if (data.characterId != null) me.characterId = data.characterId;
playPath = [];
return;
}
const o = others.get(data.id);
if (o) {
if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) {
const px = Number(data.x);
const py = Number(data.y);
if (Number.isFinite(px) && Number.isFinite(py)) {
const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py);
o.tx = sn.x;
o.ty = sn.y;
o.x = sn.x;
o.y = sn.y;
} else {
o.tx = data.x;
o.ty = data.y;
}
} else {
o.tx = data.x;
o.ty = data.y;
if (mapData && isQuiz()) {
const px = Number(data.x);
const py = Number(data.y);
if (Number.isFinite(px)) o.x = px;
if (Number.isFinite(py)) o.y = py;
}
}
o.direction = data.direction || o.direction;
if (data.characterId != null) o.characterId = data.characterId;
if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) {
const s = Number(data.spaceShooterScore);
if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s);
}
if (mapData && mapData.gameType === 'balloon_boss') {
if (data.balloonBossScore != null) {
const s = Number(data.balloonBossScore);
if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s);
}
if (data.balloonBossBossDmg != null) {
const d = Number(data.balloonBossBossDmg);
if (Number.isFinite(d)) o.balloonBossBossDmg = Math.max(0, d);
}
if (data.balloonBossBalloons != null) {
const b = Number(data.balloonBossBalloons);
if (Number.isFinite(b)) o.balloonBossBalloons = Math.max(0, Math.floor(b));
}
if (data.balloonBossEliminated != null) o.balloonBossEliminated = !!data.balloonBossEliminated;
}
}
});
socket.on('user-left', (data) => {
if (!data || isPreviewBotId(data.id)) return;
others.delete(data.id);
if (playBotsEnabled()) rebalancePreviewBots();
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase === 'howto') updateQuizQuestionMissionHowtoHud();
if (isStackTowerMissionUiMapPlay() && stackTowerMissionPhase === 'howto') updateStackTowerMissionHowtoHud();
if (isJumpSurviveMissionUiMapPlay() && jumpSurviveMissionPhase === 'howto') updateJumpSurviveMissionHowtoHud();
if (isSpaceShooterMissionUiMapPlay() && spaceShooterMissionPhase === 'howto') updateSpaceShooterMissionHowtoHud();
});
socket.on('chat', (data) => {
const box = document.getElementById('chat-messages');
if (!box) return;
const div = document.createElement('div');
div.className = 'chat-msg';
div.textContent = (data.nickname || '') + ': ' + (data.text || '');
box.appendChild(div);
box.scrollTop = 1e9;
});
socket.on('quiz-phase', (p) => {
if (previewMode) return;
if (!p || !mapData || !isQuiz()) return;
if (isQuizQuestionMissionUiMapPlay() && quizQuestionMissionPhase !== 'live' && quizQuestionMissionPhase !== 'ended') {
quizQuestionMissionDeferredPhase = p;
return;
}
applyQuizPhaseFromServer(p);
});
socket.on('quiz-result', (r) => {
if (previewMode || !r) return;
if (r.scores) {
playLiveQuizScores = { ...r.scores };
renderPlayQuizScoreboard(playLiveQuizScores);
}
if (isQuiz() && Array.isArray(r.results)) {
r.results.forEach(function (row) {
if (!row || row.id == null) return;
if (!row.right) {
playQuizEverWrong[String(row.id)] = true;
const o = others.get(row.id);
if (o) {
o.quizCannotTrue = true;
o.quizCannotFalse = true;
}
}
});
}
if (isQuiz() && Array.isArray(r.results) && r.results.some(function (row) { return row && row.right; })) {
spawnQuizTrueFalseScorePopupPlay(!!r.correctTrue);
}
showPlayQuizFeedback(r);
});
socket.on('quiz-player-state', (st) => {
if (previewMode) return;
if (!st) return;
playQuizPlayerLocal = {
cannotTrue: !!st.cannotTrue,
cannotFalse: !!st.cannotFalse,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0),
};
});
socket.on('quiz-ended', () => {
if (previewMode && isQuiz()) return;
let questionMissionEnd = null;
if (isQuizQuestionMissionUiMapPlay()) {
questionMissionEnd = quizQuestionMissionBuildPayload();
}
playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
playQuizPhaseLocal = null;
if (playQuizTimerInterval) {
clearInterval(playQuizTimerInterval);
playQuizTimerInterval = null;
}
const ov = document.getElementById('quiz-game-overlay');
if (ov) ov.classList.add('is-hidden');
const panel = document.getElementById('quiz-map-question-panel');
if (panel) {
panel.classList.add('is-hidden');
panel.setAttribute('aria-hidden', 'true');
}
playQuizText = '';
playQuizPhaseEndsAt = 0;
playLiveQuizScores = {};
hidePlayQuizHud();
if (questionMissionEnd) {
quizQuestionMissionPhase = 'ended';
quizQuestionMissionDeferredPhase = null;
applyQuizQuestionMissionPanelImages();
showGauntletCrownMissionOverlay(questionMissionEnd);
}
});
socket.on('gauntlet-sync', (data) => {
if (!data || typeof data !== 'object') return;
if (isMegaVirusMissionShellMapPlay()) {
applyGauntletTimingFromServer(data);
return;
}
if (!mapData || mapData.gameType !== 'gauntlet') return;
applyGauntletTimingFromServer(data);
if (Array.isArray(data.obstacles)) {
gauntletObstacles = data.obstacles;
pushGauntletObsRenderFrame(data.obstacles);
}
if (!Array.isArray(data.players)) return;
data.players.forEach((p) => {
if (!p || p.id == null || myId == null) return;
const pid = String(p.id);
const mid = String(myId);
if (pid === mid) {
const prevSc = Math.max(0, Number(me.gauntletScore) || 0);
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) me.tx = px;
if (Number.isFinite(py)) me.ty = py;
if (p.direction) me.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
me.gauntletScore = newSc;
if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
me.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
}
me.gauntletEliminated = !!p.gauntletEliminated;
clampGauntletCrownHeistEntityXInPlacePlay(me);
} else {
const o = others.get(p.id) ?? others.get(pid);
if (o) {
const prevSc = Math.max(0, Number(o.gauntletScore) || 0);
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) o.tx = px;
if (Number.isFinite(py)) o.ty = py;
if (p.direction) o.direction = p.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
const newSc = Number.isFinite(sc) ? Math.max(0, sc) : 0;
o.gauntletScore = newSc;
if (isGauntletCrownHeistMapPlay() && prevSc - newSc >= 10) {
o.gauntletCrownPenaltyFxUntil = Date.now() + 1100;
}
o.gauntletEliminated = !!p.gauntletEliminated;
clampGauntletCrownHeistEntityXInPlacePlay(o);
}
}
});
if (playBotsEnabled()) {
applyGauntletPreviewBotsAfterSync();
emitGauntletPreviewRowsToServer();
}
});
socket.on('gauntlet-ended', (data) => {
cancelQuizCarryResultEndAfterTimeup();
gauntletEndsAtMs = null;
gauntletCrownRunwayBgFinishLatched = false;
gauntletCrownRunwayBgStripFreezeSinceMs = 0;
gauntletCrownRunwayClientMissionShown = false;
gauntletCrownRunHeldRemote = false;
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
resetGauntletCrownRunwaySpawnScrollSnap();
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc = document.getElementById('gauntlet-crown-countdown');
if (gcc) gcc.classList.add('is-hidden');
const hto = document.getElementById('gauntlet-crown-howto-overlay');
if (hto) hto.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
if (data && data.crownMission) {
showGauntletCrownMissionOverlay(data.crownMission);
return;
}
const ov = document.getElementById('gauntlet-ended-overlay');
const msgEl = document.getElementById('gauntlet-ended-message');
const titleEl = document.getElementById('gauntlet-ended-title');
const listEl = document.getElementById('gauntlet-ended-rankings');
const btn = document.getElementById('btn-gauntlet-ended-lobby');
if (!ov || !msgEl || !listEl) return;
if (titleEl) {
titleEl.textContent = data && data.reason === 'time' ? 'หมดเวลา · Time up' : 'เกมจบ · Game over';
}
msgEl.textContent = (data && data.message) || 'เกมพรมแดงจบแล้ว';
listEl.innerHTML = '';
const ranks = (data && data.rankings) || [];
ranks.forEach((r, i) => {
const li = document.createElement('li');
const isMe = myId != null && r && String(r.id) === String(myId);
li.textContent = `${i + 1}. ${(r && r.nickname) || '—'} — ${Math.max(0, Number(r && r.score) || 0)}`;
if (isMe) li.className = 'gauntlet-ended-me';
listEl.appendChild(li);
});
ov.classList.remove('is-hidden');
function goLobby() {
window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick);
}
if (btn) {
btn.onclick = () => {
if (previewMode && editorEmbedReturn) ov.classList.add('is-hidden');
else goLobby();
};
}
});
socket.on('game-start', (ev) => {
if (!ev || !ev.mapId) return;
const urlMap = (params.get('map') || '').trim();
if (urlMap && ev.stayInRoomLobby && String(ev.mapId).trim() !== urlMap) {
return;
}
const applySnap = (md) => {
mapData = md;
playEmbedUserZoomMul = 1;
if (ev.mapId != null && String(ev.mapId).trim() !== '') {
playSessionMapId = String(ev.mapId).trim();
}
applyPlayEmbedZoomForCurrentMapPlay();
if (!isQuizQuestionMissionUiMapPlay()) {
quizQuestionMissionPhase = null;
quizQuestionMissionDeferredPhase = null;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
}
if (!isStackTowerMissionUiMapPlay()) {
teardownStackTowerMissionUiPlay();
}
if (mapData.gameType !== 'space_shooter') {
spaceShooterGameEnded = false;
spaceShooterMissionPhase = null;
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
}
if (mapData.gameType !== 'balloon_boss') {
balloonBossGameEnded = false;
}
if (!mapData.lanes && mapData.gameType === 'frogger') mapData.lanes = [];
tileSize = mapData.tileSize || 32;
gauntletObstacles = [];
gauntletObsRenderPrev = [];
gauntletObsRenderNext = [];
gauntletObsBlendT0 = 0;
mapBackgroundImg = null;
if (mapData.backgroundImage) {
mapBackgroundImg = new Image();
mapBackgroundImg.src = mapData.backgroundImage;
}
reloadPlayScrollBgFromMap();
reloadStackTowerScrollBgFromMap();
reloadGauntletCrownRunwayBgFromMap();
normalizeGridImageCellsOnMap(mapData);
loadMapGridImages();
if (mapData.gameType === 'gauntlet' && ev.peersSnap && Array.isArray(ev.peersSnap)) {
ev.peersSnap.forEach((p) => {
if (p.id != null && myId != null && String(p.id) === String(myId)) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { me.x = px; me.tx = px; }
if (Number.isFinite(py)) { me.y = py; me.ty = py; }
me.direction = p.direction || me.direction;
const jt = Number(p.gauntletJumpTicks);
meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
meGauntletJumpVis = meGauntletJumpTicks;
const sc = Number(p.gauntletScore);
me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
me.gauntletEliminated = !!p.gauntletEliminated;
} else {
const o = others.get(p.id) ?? others.get(String(p.id));
if (o) {
const px = Number(p.x);
const py = Number(p.y);
if (Number.isFinite(px)) { o.x = px; o.tx = px; }
if (Number.isFinite(py)) { o.y = py; o.ty = py; }
o.direction = p.direction || o.direction;
const jt = Number(p.gauntletJumpTicks);
o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0;
o.gauntletJumpVis = o.gauntletJumpTicks;
const sc = Number(p.gauntletScore);
o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0;
o.gauntletEliminated = !!p.gauntletEliminated;
}
}
});
} else {
meGauntletJumpTicks = 0;
meGauntletJumpVis = 0;
me.gauntletScore = 0;
me.gauntletEliminated = false;
others.forEach((o) => {
o.gauntletJumpTicks = 0;
o.gauntletJumpVis = 0;
o.gauntletScore = 0;
o.gauntletEliminated = false;
});
}
if (mapData.gameType === 'gauntlet') {
if (ev.gauntletEndsAt != null) {
const ge = Number(ev.gauntletEndsAt);
gauntletEndsAtMs = Number.isFinite(ge) ? ge : null;
} else {
gauntletEndsAtMs = null;
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {});
if (String(ev.mapId || '') === GAUNTLET_FACE_RIGHT_MAP_ID) {
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc0 = document.getElementById('gauntlet-crown-countdown');
if (gcc0) gcc0.classList.add('is-hidden');
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
} else {
const hto2 = document.getElementById('gauntlet-crown-howto-overlay');
if (hto2) hto2.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc1 = document.getElementById('gauntlet-crown-countdown');
if (gcc1) gcc1.classList.add('is-hidden');
}
} else {
gauntletEndsAtMs = null;
const megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID;
const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay())
|| (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay())
|| (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay())
|| (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay())
|| megaShellGs;
if (!skipHideCrownShell) {
const hto3 = document.getElementById('gauntlet-crown-howto-overlay');
if (hto3) hto3.classList.add('is-hidden');
gauntletCrownHowtoVisible = false;
}
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gcc2 = document.getElementById('gauntlet-crown-countdown');
if (gcc2) gcc2.classList.add('is-hidden');
}
if (mapData.gameType !== 'gauntlet') {
me.tx = null;
me.ty = null;
}
const gauntletOv = document.getElementById('gauntlet-ended-overlay');
if (gauntletOv) gauntletOv.classList.add('is-hidden');
const gcmOv = document.getElementById('gauntlet-crown-mission-overlay');
if (gcmOv) gcmOv.classList.add('is-hidden');
if (mapData.gameType === 'stack') {
if (!mapData.stackReleaseArea) mapData.stackReleaseArea = [];
if (!mapData.stackLandArea) mapData.stackLandArea = [];
rebalancePreviewBots();
lastStackTickMs = performance.now();
resetStackMinigameState();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
else reapplyStackMiniSizingFromGlobals();
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
})
.catch(() => {
if (stackMini && isStack()) stackMini.phaseSpeed = playStackSwingHz;
});
if (isStackTowerMissionUiMapPlay()) {
stackTowerMissionEndedOnce = false;
stackTowerMissionDeferredPhase = null;
if (stackTowerMissionCountdownTimer) {
clearTimeout(stackTowerMissionCountdownTimer);
stackTowerMissionCountdownTimer = null;
}
stackTowerMissionPhase = 'howto';
stackTowerSessionStartAt = 0;
hideConflictingOverlaysForGauntletCrown();
applyStackTowerMissionPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
setTimeout(function () { showStackTowerMissionHowtoOverlay(); }, 60);
} else {
teardownStackTowerMissionUiPlay();
}
} else {
stackMini = null;
teardownStackTowerMissionUiPlay();
}
if (mapData.gameType === 'jump_survive') {
if (!Array.isArray(mapData.jumpSurvivePlatforms)) mapData.jumpSurvivePlatforms = [];
if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = [];
if (!mapData.jumpSurviveHazardArea) mapData.jumpSurviveHazardArea = [];
if (!mapData.jumpSurvivePlatformVariantArea) mapData.jumpSurvivePlatformVariantArea = [];
normalizeJumpSurvivePlatformAreaInPlay(mapData);
normalizeJumpSurvivePlatformVariantAreaInPlay(mapData);
normalizeJumpSurviveHazardAreaInPlay(mapData);
normalizeShooterSpawnSlotsInPlay(mapData);
jumpSurviveEliminated = false;
jumpSurviveGameEnded = false;
if (jumperMissionCountdownTimer) {
clearTimeout(jumperMissionCountdownTimer);
jumperMissionCountdownTimer = null;
}
if (isJumpSurviveMissionUiMapPlay()) {
jumpSurviveMissionPhase = 'howto';
hideConflictingOverlaysForJumpSurviveMission();
applyJumpSurviveJumperPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60);
} else {
jumpSurviveMissionPhase = null;
jumpSurviveInitRuntime();
}
}
if (mapData.gameType === 'space_shooter') {
normalizeShooterSpawnSlotsInPlay(mapData);
if (spaceShooterMissionCountdownTimer) {
clearTimeout(spaceShooterMissionCountdownTimer);
spaceShooterMissionCountdownTimer = null;
}
if (isSpaceShooterMissionUiMapPlay()) {
spaceShooterMissionPhase = 'howto';
hideConflictingOverlaysForGauntletCrown();
applySpaceShooterMissionPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
setTimeout(function () { showSpaceShooterMissionHowtoOverlay(); }, 60);
} else {
spaceShooterMissionPhase = null;
spaceShooterGameEnded = false;
spaceShooterBullets = [];
spaceShooterAsteroids = [];
spaceShooterAsteroidExplosions = [];
spaceShooterPopups = [];
spaceShooterLastTickMs = performance.now();
spaceShooterSpawnAccMs = 0;
spaceShooterFireCd = 0;
spaceShooterSessionStartMs = performance.now();
spaceShooterLastMoveEmit = 0;
}
}
if (mapData.gameType === 'quiz') {
if (!mapData.quizTrueArea) mapData.quizTrueArea = [];
if (!mapData.quizFalseArea) mapData.quizFalseArea = [];
if (!mapData.quizQuestionArea) mapData.quizQuestionArea = [];
}
if (mapData.gameType === 'balloon_boss') {
balloonBossGameEnded = false;
normalizeBalloonBossPlayerSlotsInPlay(mapData);
balloonBossPendingShots = [];
balloonBossPlayerBullets = [];
balloonBossBossBullets = [];
balloonBossHitFx = [];
balloonBossScorePopups = [];
balloonBossLastTickMs = performance.now();
balloonBossPlayerFireCd = 0;
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
me.balloonBossScore = 0;
me.balloonBossBossDmg = 0;
me.balloonBossBalloons = balloonBossBalloonsStartPlay();
me.balloonBossEliminated = false;
others.forEach((o) => {
o.balloonBossScore = 0;
o.balloonBossBossDmg = 0;
o.balloonBossBalloons = balloonBossBalloonsStartPlay();
o.balloonBossEliminated = false;
});
if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) {
balloonBossSessionStartMs = 0;
if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
}
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => {
if (t) applyGauntletTimingFromServer(t);
if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) {
applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld });
}
})
.catch(() => {});
gauntletCrownPregamePhase = null;
gauntletCrownLobbyReadyMap = {};
if (gauntletCrownCountdownTimer) {
clearTimeout(gauntletCrownCountdownTimer);
gauntletCrownCountdownTimer = null;
}
const gccMv = document.getElementById('gauntlet-crown-countdown');
if (gccMv) gccMv.classList.add('is-hidden');
setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60);
} else {
balloonBossSessionStartMs = performance.now();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
}
}
if (mapData.gameType === 'quiz_battle') {
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
if (!mapData.quizBattlePathArea) mapData.quizBattlePathArea = [];
normalizeQuizBattlePathInPlay(mapData);
normalizeQuizBattleDomeInPlay(mapData);
if (quizBattlePathModeActive(mapData)) {
const ms = snapPositionOntoQuizBattlePathIfNeeded(me.x, me.y);
me.x = ms.x;
me.y = ms.y;
others.forEach((o) => {
const s = snapPositionOntoQuizBattlePathIfNeeded(o.x, o.y);
o.x = s.x;
o.y = s.y;
});
}
}
var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน'
: (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)'
: (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : (isQuizCarry() ? ' | หยิบคำตอบมาวางกลาง' : (isQuizBattle() ? ' | Quiz Battle · โดม E' : (isStack() ? ' | Stack ซ้อนตึก' : (isJumpSurvive() ? ' | กระโดดให้รอด' : (isSpaceShooter() ? ' | ยิงยานอวกาศ' : (isBalloonBoss() ? ' | ลูกโป้งยิงบอส' : '')))))))));
var prevTag = previewMode ? '[ทดสอบ] ' : '';
document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel;
if (isQuiz()) {
setupPlayQuizUi();
if (isQuizQuestionMissionUiMapPlay()) {
quizQuestionMissionDeferredPhase = null;
if (quizQuestionMissionCountdownTimer) {
clearTimeout(quizQuestionMissionCountdownTimer);
quizQuestionMissionCountdownTimer = null;
}
quizQuestionMissionPhase = 'howto';
hideConflictingOverlaysForGauntletCrown();
applyQuizQuestionMissionPanelImages();
fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((t) => { if (t) applyGauntletTimingFromServer(t); })
.catch(() => {});
setTimeout(function () { showQuizQuestionMissionHowtoOverlay(); }, 60);
}
} else if (isQuizCarry()) setupPlayQuizCarryUi();
else if (isQuizBattle()) setupPlayQuizBattleUi();
else teardownPlayQuizUi();
resizeCanvas();
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
if (playBotsEnabled() && mapData.gameType === 'gauntlet') {
applyGauntletPreviewSpawnLayout(true);
emitGauntletPreviewRowsToServer();
}
if (playBotsEnabled() && mapData.gameType === 'jump_survive') {
applyJumpSurvivePreviewSpawnLayout(true);
}
if (playBotsEnabled() && mapData.gameType === 'space_shooter') {
applySpaceShooterSpawnLayoutPlay();
}
if (playBotsEnabled() && mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
}
};
fetch(BASE + '/api/maps/' + encodeURIComponent(ev.mapId))
.then((r) => (r.ok ? r.json() : null))
.then((md) => { if (md) applySnap(md); });
});
function resizeCanvas() {
if (mapData && isStackTowerMissionUiMapPlay() && canvas) {
canvas.width = Math.max(320, STACK_TOWER_FIXED_RENDER_W);
canvas.height = Math.max(240, STACK_TOWER_FIXED_RENDER_H);
/* รีคำนวน BG scroll หลังขนาดบัฟเฟอร์คงที่ — กันรูปโหลดเร็วตอนแคนวาสยังไม่ล็อกแล้วไม่ซิงก์ใหม่ */
stackTowerScrollBgSrawWorldBaselinePlay = null;
stackTowerScrollBgSyncInitialScrollToBottom();
syncQuizCarryEmbedCountdownLayout();
return;
}
const vw = window.innerWidth || document.documentElement.clientWidth || 800;
const vh = window.innerHeight || document.documentElement.clientHeight || 600;
const header = document.querySelector('.game-header');
const headerH = playHideTopChrome() ? 0 : (header && header.offsetHeight ? header.offsetHeight : 48);
const stage = document.getElementById('play-canvas-stage');
const stackEl = document.getElementById('play-canvas-stack');
const parent = stage || (canvas && canvas.parentElement);
let cw = canvas.clientWidth || 0;
let ch = canvas.clientHeight || 0;
if (parent && parent.clientWidth > 80) cw = Math.max(cw, parent.clientWidth);
if (parent && parent.clientHeight > 80) ch = Math.max(ch, parent.clientHeight);
/* จอใหญ่ / iframe embed: client บางทียัง 0 ช้ากว่า layout — ใช้ offset เป็นทางเลือก */
if (parent) {
const ow = parent.offsetWidth || 0;
const oh = parent.offsetHeight || 0;
if (ow > 80) cw = Math.max(cw, ow);
if (oh > 80) ch = Math.max(ch, oh);
}
/* embed: stage อาจยัง 0 แต่ stack วัดได้แล้ว — อย่าให้ fallback เป็น vw/vh เต็มจอแล้ว CSS ย่อเข้า stage = ดูชิดมุม */
if (previewMode && editorEmbedReturn && stackEl) {
const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
const sh = Math.max(stackEl.clientHeight || 0, stackEl.offsetHeight || 0);
if (sw > 80) cw = Math.max(cw, sw);
if (sh > 80) ch = Math.max(ch, sh);
}
if (ch < 120) ch = Math.max(240, vh - headerH - 6);
if (cw < 120) {
if (previewMode && editorEmbedReturn && stackEl) {
const sw = Math.max(stackEl.clientWidth || 0, stackEl.offsetWidth || 0);
cw = sw > 80 ? Math.max(320, sw) : Math.max(320, vw);
} else cw = Math.max(320, vw);
}
canvas.width = Math.max(320, cw);
canvas.height = Math.max(240, ch);
syncQuizCarryEmbedCountdownLayout();
}
canvas.addEventListener('click', (e) => {
if (!mapData || !isStack() || isChatFocused()) return;
tryHumanStackDrop();
});
(function setupPlayEditorEmbedViewZoom() {
const stack = document.getElementById('play-canvas-stack');
if (!stack) return;
const wheelZoomHandler = (e) => {
if (!previewMode || !editorEmbedReturn || !mapData) return;
if (isStackTowerEmbedZoomLockedPlay()) return;
if (isQuizQuestionMissionHudActivePlay()) return;
const t = e.target;
if (t && typeof t.closest === 'function') {
if (t.closest('button, input, textarea, select, a[href]')) return;
}
/* ล้อธรรม / trackpad pinch มักไม่มี ctrl — ให้ซูมได้ใน embed; Ctrl/⌘+ล้อยังใช้ได้ */
e.preventDefault();
e.stopPropagation();
const raw = e.deltaMode === 1 ? e.deltaY * 14 : e.deltaMode === 2 ? e.deltaY * 320 : e.deltaY;
const k = Math.exp(-raw * 0.002);
playEmbedUserZoomMul = Math.max(
PLAY_EMBED_USER_ZOOM_MIN,
Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * k),
);
draw();
};
stack.addEventListener('wheel', wheelZoomHandler, { passive: false, capture: true });
})();
canvas.addEventListener('dblclick', (e) => {
if (!mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss() || isChatFocused() || isQuizCarryEmbedCountdownBlockingMovement()) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const qmC = isQuizQuestionMissionHudActivePlay() ? getQuizQuestionMissionMapCenterWorldPxPlay() : null;
const carryCam = !qmC && isQuizCarry() ? getQuizCarryMapCameraWorldCenterPxPlay() : null;
const camX = qmC ? qmC.cx : (carryCam ? carryCam.cx : me.x * tileSize);
const camY = qmC ? qmC.cy : (carryCam ? carryCam.cy : me.y * tileSize);
const zHit = lastPlayZDrawForInput > 0 ? lastPlayZDrawForInput : zoom;
const gx = (sx - canvas.width / 2) / zHit + camX;
const gy = (sy - canvas.height / 2) / zHit + camY;
const tx = Math.floor(gx / tileSize);
const ty = Math.floor(gy / tileSize);
const mw = mapData.width || 20, mh = mapData.height || 15;
if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return;
if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return;
const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5);
if (path.length <= 1) return;
playPath = path.slice(1);
});
function getCharacterFootprintWH(md) {
if (!md) return { cw: 1, ch: 1 };
let cw = Math.floor(Number(md.characterCellsW));
let ch = Math.floor(Number(md.characterCellsH));
const hasW = Number.isFinite(cw) && cw >= 1;
const hasH = Number.isFinite(ch) && ch >= 1;
if (hasW && hasH) {
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
}
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
return { cw: leg, ch: leg };
}
/**
* ขนาดช่องที่ใช้ชนกำแพง (objects=1) เท่านั้น — ชิดเท้า (ล่าง) + กลางแนวนอน · เล็กกว่า footprint = ส่วนบนไม่ติดกำแพง
*/
function getCharacterCollisionFootprintWH(md) {
const { cw, ch } = getCharacterFootprintWH(md);
let colW = Math.floor(Number(md.characterCollisionW));
let colH = Math.floor(Number(md.characterCollisionH));
if (!Number.isFinite(colW) || colW < 1) colW = cw;
if (!Number.isFinite(colH) || colH < 1) colH = ch;
colW = Math.max(1, Math.min(cw, colW));
colH = Math.max(1, Math.min(ch, colH));
return { cw, ch, colW, colH };
}
function getGauntletCrownHeistMaxEntityXPlay(md) {
if (!md || !isGauntletCrownHeistMapPlay()) return null;
const w = md.width || 20;
const { cw } = getCharacterFootprintWH(md);
const capRight = GAUNTLET_CROWN_HEIST_MAX_X_WORLD_FRAC * w - cw;
const fullMapMax = Math.max(0, w - cw + 0.999);
return Math.max(0, Math.min(fullMapMax, capRight));
}
function clampGauntletCrownHeistEntityXInPlacePlay(ent) {
if (!ent || !mapData || !isGauntletCrownHeistMapPlay()) return;
const mx = getGauntletCrownHeistMaxEntityXPlay(mapData);
if (mx == null || !Number.isFinite(mx)) return;
if (Number.isFinite(ent.x)) ent.x = Math.max(0, Math.min(mx, ent.x));
if (ent.tx != null && Number.isFinite(ent.tx)) ent.tx = Math.max(0, Math.min(mx, ent.tx));
}
/** กันตัวละคร footprint ล้นออกนอกแมป — เดิมใช้ w-0.01 ทำให้ cw/ch>1 ชนขอบแล้วตรรกะเดินค้าง */
function clampPlayEntityFootprintToMap(oEnt, md) {
if (!oEnt || !md) return;
const w = md.width || 20, h = md.height || 15;
const { cw, ch } = getCharacterFootprintWH(md);
let maxX = Math.max(0, w - cw + 0.999);
const crownMx = getGauntletCrownHeistMaxEntityXPlay(md);
if (crownMx != null) maxX = Math.min(maxX, crownMx);
const maxY = Math.max(0, h - ch + 0.999);
if (!Number.isFinite(oEnt.x)) oEnt.x = 0.5;
if (!Number.isFinite(oEnt.y)) oEnt.y = 0.5;
oEnt.x = Math.max(0, Math.min(maxX, oEnt.x));
oEnt.y = Math.max(0, Math.min(maxY, oEnt.y));
}
/** แยกบอท preview ที่ทับกันบนจุดเดียว (มักชนกันที่ขอบแมป) */
function separateClumpedPreviewBots() {
if (!playBotsEnabled() || !mapData) return;
const ids = [...others.keys()].filter(isPreviewBotId);
const minD = 0.52;
const pushAmt = MOVE_SPEED * 0.5;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = others.get(ids[i]);
const b = others.get(ids[j]);
if (!a || !b) continue;
let ddx = b.x - a.x, ddy = b.y - a.y;
let d = Math.sqrt(ddx * ddx + ddy * ddy);
if (d >= minD) continue;
if (d < 1e-5) {
ddx = (i + j * 2) % 2 === 0 ? 1 : -1;
ddy = ((i + j) % 3) - 1;
d = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
}
const ux = -ddx / d, uy = -ddy / d;
const vx = ddx / d, vy = ddy / d;
const ax2 = a.x + ux * pushAmt, ay2 = a.y + uy * pushAmt;
const bx2 = b.x + vx * pushAmt, by2 = b.y + vy * pushAmt;
if (canWalkLikeLobbyForBot(ax2, ay2, a.x, a.y, a)) { a.x = ax2; a.y = ay2; }
if (canWalkLikeLobbyForBot(bx2, by2, b.x, b.y, b)) { b.x = bx2; b.y = by2; }
clampPlayEntityFootprintToMap(a, mapData);
clampPlayEntityFootprintToMap(b, mapData);
}
}
}
function quizTilesFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
const { cw, ch } = getCharacterFootprintWH(mapData);
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const maxTx = Math.min(w - 1, minTx + cw - 1);
const maxTy = Math.min(h - 1, minTy + ch - 1);
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) s.add(tx + ',' + ty);
}
}
return s;
}
/** ร่าง (cw×ch) ของผู้เล่นที่ (px,py) ครอบคลุมช่อง (tx,ty) หรือไม่ — ใช้กับ blockPlayer แทนการเช็คแค่มุมซ้ายบน */
function playEntityFootprintContainsTile(px, py, tx, ty) {
return quizTilesFootprintPlay(px, py).has(tx + ',' + ty);
}
/**
* กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า
* blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch
* รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON
*/
function resolveBlockPlayerNoOverlapBoxWH(md) {
const m = md || mapData;
if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 };
const { cw, ch } = getCharacterFootprintWH(m);
let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN;
let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN;
const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN;
const hasW = Number.isFinite(w) && w >= 0;
const hasH = Number.isFinite(h) && h >= 0;
if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) {
w = Math.min(leg, cw);
h = Math.min(leg, ch);
} else {
if (!hasW) w = 0;
if (!hasH) h = 0;
}
const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w));
const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h));
return { cw, ch, boxW, boxH };
}
/** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */
function quizTilesBlockPlayerPeerFootprintPlay(px, py) {
const s = new Set();
if (!mapData) return s;
if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s;
const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData);
const w = mapData.width || 20, h = mapData.height || 15;
const minTx = Math.floor(px);
const minTy = Math.floor(py);
const offX = minTx + Math.floor((cw - boxW) / 2);
const offY = minTy + (ch - boxH);
for (let ty = offY; ty < offY + boxH; ty++) {
for (let tx = offX; tx < offX + boxW; tx++) {
if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty);
}
}
return s;
}
function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) {
return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty);
}
/** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */
function quizTilesWallCollisionFootprintPlay(px, py) {
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 carryWalk = isQuizCarry() ? quizCarryWalkSpeedMultActive : 1;
const baseEmbedCarryPathMul = 0.74;
const pathStepMul = (previewFillBots && editorEmbedReturn)
? (isQuizCarry() ? baseEmbedCarryPathMul * (quizCarryWalkSpeedMultActive / QUIZ_CARRY_WALK_SPEED_MULT) : 0.56)
: 1;
const step = Math.min(MOVE_SPEED * 1.28 * pathStepMul * carryWalk, len);
const nx = o.x + (dx / len) * step;
const ny = o.y + (dy / len) * step;
if (Math.abs(dy) > Math.abs(dx)) o.direction = dy > 0 ? 'down' : 'up';
else if (Math.abs(dx) > 1e-6) o.direction = dx > 0 ? 'right' : 'left';
const ox = o.x, oy = o.y;
const pathStrict = isQuizBattle() && quizBattlePathModeActive(mapData);
if (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 * carryWalk, Math.max(Math.abs(dx), Math.abs(dy), 1e-4));
const sx = dx > 1e-4 ? 1 : dx < -1e-4 ? -1 : 0;
const sy = dy > 1e-4 ? 1 : dy < -1e-4 ? -1 : 0;
const tryAxisX = () => {
if (sx === 0) return false;
if (!canWalkLikeLobbyForBot(o.x + sx * cstep, o.y, o.x, o.y, o)) return false;
o.x += sx * cstep;
return true;
};
const tryAxisY = () => {
if (sy === 0) return false;
if (!canWalkLikeLobbyForBot(o.x, o.y + sy * cstep, o.x, o.y, o)) return false;
o.y += sy * cstep;
return true;
};
if (Math.abs(dx) >= Math.abs(dy)) {
if (!tryAxisX()) tryAxisY();
} else {
if (!tryAxisY()) tryAxisX();
}
}
}
clampPlayEntityFootprintToMap(o, mapData);
if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) {
o.botIsWalking = true;
o.botPathStuckTicks = 0;
} else {
o.botPathStuckTicks = (o.botPathStuckTicks | 0) + 1;
if ((o.botPathStuckTicks | 0) >= 20) {
o.botPathStuckTicks = 0;
if (path.length) path.shift();
if (isQuizCarry() && playBotsEnabled()) o.botQuizCarryPathfindAfter = 0;
}
}
}
function pickRandomPreviewBotWanderDir() {
const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
return dirs[Math.floor(Math.random() * dirs.length)];
}
function stepPreviewBots() {
if (!playBotsEnabled() || !mapData || isFrogger() || isGauntlet() || isStack() || isJumpSurvive() || isSpaceShooter() || isBalloonBoss()) return;
if (isQuizCarry()) {
stepQuizCarryPreviewBots();
return;
}
const w = mapData.width || 20, h = mapData.height || 15;
const now = Date.now();
const inAnswerPhase = previewMode && isQuiz() && previewQuizStep === 'answer';
others.forEach((o, id) => {
if (!isPreviewBotId(id)) return;
o.botIsWalking = false;
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();
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);
}
const lineReady = !!(lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && rh > 1);
/* ไม่เติมสีคอลัมน์ทึบเมื่อมีรูปเส้นแล้ว — กันทึบโปร่งซ้าย/ขวาเลเซอร์ดูเหมือนแถบ UI/scrollbar */
if (!lineReady) {
ctx.fillStyle = gauntletLaserFillColor;
ctx.fillRect(rx, ry, rw, rh);
}
/* เส้นกลาง tile ทั้งความสูงคอลัมน์ แล้วค่อยวาดหัว/ท้ายทับ — ให้ลำแสงต่อเนื่องแบบสินทรัพย์รวม (รูปอ้างอิง) */
if (lineReady) {
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 towerMapHud = isStackTowerMissionUiMapPlay();
const lifeSlots = towerMapHud
? Math.max(1, Math.min(20, Math.floor(Number(playStackTeamMissesMax) || 3)))
: 3;
const livesRem = towerMapHud && stackMini.teamMissesLeft != null
? Math.max(0, Number(stackMini.teamMissesLeft) || 0)
: Math.max(0, stackMini.lives != null ? stackMini.lives : 3);
const teamScore = stackMini.score || 0;
const combo = stackMini.combo || 0;
function roundRectPath(x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
/** @param {{ headBust?: boolean, headSrcFrac?: number, bustLiftPx?: number }} [avatarOpts] — headBust + bustLiftPx ดันรูปขึ้นในกรอบ */
function drawHudAvatar(cx, baselineY, maxSize, characterId, playTint, avatarOpts) {
const timeMs = now;
const dir = 'down';
const rawImg = getAvatarImg(characterId, dir, timeMs, false);
const charImg = playTint ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, false, playTint) : rawImg;
let iw = 0; let ih = 0;
if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
iw = charImg.width;
ih = charImg.height;
} else if (charImg && charImg.complete && charImg.naturalWidth) {
iw = charImg.naturalWidth;
ih = charImg.naturalHeight;
}
const headBust = !!(avatarOpts && avatarOpts.headBust);
let sx = 0;
let sy = 0;
let sww = iw;
let shh = ih;
if (headBust && iw > 0 && ih > 0) {
const rawF = Number(avatarOpts && avatarOpts.headSrcFrac);
const frac = Number.isFinite(rawF) && rawF > 0.22 && rawF < 0.85 ? rawF : 0.4;
shh = Math.max(8, Math.round(ih * frac));
}
const ax = cx - maxSize / 2;
const ay = baselineY - maxSize;
ctx.save();
roundRectPath(ax, ay, maxSize, maxSize, 5);
ctx.clip();
if (iw > 0 && ih > 0) {
const maxScale = headBust ? 2.45 : 1;
const scale = Math.min(maxSize / sww, maxSize / shh, maxScale);
const dw = sww * scale;
const dh = shh * scale;
const rawLift = headBust && avatarOpts ? Number(avatarOpts.bustLiftPx) : NaN;
const bustLift = headBust
? (Number.isFinite(rawLift) ? Math.max(0, Math.min(maxSize * 0.45, rawLift)) : 22)
: 0;
ctx.drawImage(charImg, sx, sy, sww, shh, cx - dw / 2, baselineY - dh - bustLift, dw, dh);
} else {
ctx.fillStyle = 'rgba(80, 200, 255, 0.35)';
ctx.fillRect(ax, ay, maxSize, maxSize);
}
ctx.restore();
ctx.strokeStyle = 'rgba(0, 234, 255, 0.5)';
ctx.lineWidth = 1.5;
roundRectPath(ax, ay, maxSize, maxSize, 5);
ctx.stroke();
}
function resolveEntryVisual(entry) {
if (entry.kind === 'human') {
return {
name: String(me.nickname || 'YOU').toUpperCase(),
score: me.stackPreviewHumanPts || 0,
characterId: me.characterId,
playTint: me.playTint || resolvePlayTintForPeer(String(myId || 'me')),
};
}
const o = entry.botId ? others.get(entry.botId) : null;
return {
name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—',
score: o ? (o.stackBotScore || 0) : 0,
characterId: o ? o.characterId : null,
playTint: o ? (o.playTint || resolvePlayTintForPeer(entry.botId)) : null,
};
}
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const boardW = Math.min(248, Math.max(200, cw * 0.36));
const rowH = 42;
const headH = 38;
const boardH = headH + STACK_PREVIEW_TURN_COUNT * rowH + 10;
if (!towerMapHud) {
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
roundRectPath(pad, 8, boardW, boardH, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 230, 255, 0.5)';
ctx.lineWidth = 1.5;
roundRectPath(pad, 8, boardW, boardH, 8);
ctx.stroke();
ctx.fillStyle = '#5cefff';
ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('SCORE', pad + 14, 16);
ctx.fillStyle = 'rgba(94, 239, 255, 0.55)';
ctx.font = monoSm;
ctx.fillText('shared stack · P1–P6', pad + 62, 18);
let rowY = 8 + headH;
const drawRows = (entries) => {
entries.forEach((entry) => {
const isCurrent = entry.seat === currentSeat;
const v = resolveEntryVisual(entry);
let actionFlash = false;
if (entry.kind === 'human') {
actionFlash = lastStackPreviewActorId === '__human__' && now < lastStackPreviewActorUntil;
} else if (entry.botId) {
const ob = others.get(entry.botId);
actionFlash = !!(ob && ((now < (ob.stackBotGlowUntil || 0)) || (entry.botId === lastStackPreviewActorId && now < lastStackPreviewActorUntil)));
}
if (isCurrent) {
ctx.fillStyle = 'rgba(255, 0, 180, 0.1)';
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 255, 220, 0.9)';
ctx.lineWidth = 2;
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.stroke();
} else if (actionFlash) {
ctx.strokeStyle = 'rgba(255, 214, 102, 0.65)';
ctx.lineWidth = 1.5;
roundRectPath(pad + 6, rowY - 1, boardW - 12, rowH - 2, 4);
ctx.stroke();
}
const avS = 34;
drawHudAvatar(pad + 10 + avS / 2, rowY + avS - 3, avS, v.characterId, v.playTint);
ctx.fillStyle = isCurrent ? '#e0f7ff' : '#89b4fa';
ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText('P' + entry.seat, pad + 14 + avS + 6, rowY + 12);
ctx.fillStyle = '#c0caf5';
ctx.font = 'bold 10px ' + mono;
ctx.fillText(v.name.slice(0, 11), pad + 14 + avS + 6, rowY + 28);
ctx.fillStyle = actionFlash ? '#ffe066' : '#7dfcff';
ctx.font = 'bold 13px ' + mono;
ctx.textAlign = 'right';
ctx.fillText(String(v.score), pad + boardW - 12, rowY + rowH / 2);
ctx.textAlign = 'left';
rowY += rowH;
});
};
if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
drawRows(order);
} else {
const bots = [...others.keys()].filter(isPreviewBotId).sort();
const rows = [{ kind: 'human', seat: 1 }];
for (let i = 0; i < STACK_PREVIEW_TURN_COUNT - 1; i++) {
rows.push({ kind: 'bot', seat: i + 2, botId: bots[i] || null });
}
drawRows(rows);
}
}
const framePad = 10;
const bigAv = Math.min(72, Math.max(52, ch * 0.10));
const avBox = bigAv + 10;
const integrityMinW = 120;
const rightW = Math.min(300, Math.max(236, Math.round(cw * 0.34)));
let rightX = cw - pad - rightW;
const topY = 8;
const leftReserve = towerMapHud ? (pad + 8) : (pad + boardW + 14);
if (rightX < leftReserve) {
rightX = Math.max(pad, cw - pad - rightW);
}
const integrityW = Math.max(integrityMinW, rightW - framePad * 2 - avBox - 10);
const innerTop = topY + framePad;
const integrityX = rightX + framePad;
const integrityH = avBox + 2;
const avFrameX = integrityX + integrityW + 10;
const avFrameY = innerTop;
const footerH = towerMapHud ? 0 : 26;
const turnPanelH = framePad + integrityH + (towerMapHud ? framePad : (10 + footerH + framePad));
ctx.fillStyle = 'rgba(8, 12, 28, 0.9)';
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 230, 255, 0.55)';
ctx.lineWidth = 1.5;
roundRectPath(rightX, topY, rightW, turnPanelH, 8);
ctx.stroke();
let curEntry = null;
if (order && order.length === STACK_PREVIEW_TURN_COUNT) {
curEntry = order.find((e) => e.seat === currentSeat) || null;
}
const curV = curEntry ? resolveEntryVisual(curEntry) : resolveEntryVisual({ kind: 'human', seat: 1 });
drawStackTowerLifeIntegrityBarPlay(ctx, integrityX, innerTop - 2, integrityW, integrityH + 4, lifeSlots, livesRem);
const avCx = avFrameX + avBox / 2;
ctx.save();
ctx.shadowColor = 'rgba(0, 234, 255, 0.5)';
ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(0, 234, 255, 0.92)';
ctx.lineWidth = 2;
roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(0, 234, 255, 0.45)';
ctx.lineWidth = 1;
roundRectPath(avFrameX - 1, avFrameY - 1, avBox + 2, avBox + 2, 6);
ctx.stroke();
drawHudAvatar(avCx, avFrameY + avBox - 5, bigAv, curV.characterId, curV.playTint, towerMapHud ? { headBust: true } : undefined);
if (!towerMapHud) {
const footY = innerTop + integrityH + 12;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = livesRem <= 1 ? '#f7768e' : 'rgba(125, 252, 255, 0.88)';
ctx.font = monoSm;
ctx.fillText(
'P' + currentSeat + ' · ' + curV.name.slice(0, 9) + ' · TEAM ' + teamScore + ' · COMBO x' + combo,
rightX + rightW / 2,
footY,
);
}
const termW = Math.min(380, Math.max(220, cw * 0.42));
const logLineH = 12;
const logMaxLines = 5;
const logBlockH = logMaxLines * logLineH;
/** หัวข้อ + บล็อกล็อก + ช่องว่าง + ป้าย TOWER_LINK + แถบ — อย่าให้ทับกัน (เดิม termH=86 แคบเกิน) */
const termH = Math.max(118, 22 + logBlockH + 10 + 14 + 8 + 10);
const termY = Math.max((towerMapHud ? 28 : boardH + 24), ch - termH - 52);
ctx.fillStyle = 'rgba(18, 8, 32, 0.88)';
roundRectPath(pad, termY, termW, termH, 6);
ctx.fill();
ctx.strokeStyle = 'rgba(187, 100, 255, 0.45)';
ctx.lineWidth = 1.5;
roundRectPath(pad, termY, termW, termH, 6);
ctx.stroke();
ctx.fillStyle = '#c099ff';
ctx.font = '600 10px ' + mono;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('// NODE_LOG', pad + 10, termY + 8);
ctx.fillStyle = '#7dcfff';
ctx.font = monoSm;
const logTop = termY + 24;
let ly = logTop;
const logLines = stackPreviewHudLog.length
? stackPreviewHudLog.slice(-logMaxLines)
: ['>>> Awaiting stack sync…', '>>> Space / click = release (your turn only)'];
logLines.forEach((ln) => {
ctx.fillText(ln, pad + 10, ly);
ly += logLineH;
});
const decryptPct = stackTowerDecryptPctPlay();
const barPadX = 10;
const barH = 8;
const barW = termW - barPadX * 2;
const barY = termY + termH - barH - 10;
const labelY = barY - 14;
if (labelY >= logTop + 4) {
ctx.fillStyle = '#94e2d5';
ctx.font = monoSm;
ctx.textBaseline = 'top';
ctx.fillText('TOWER_LINK ' + decryptPct + '%', pad + barPadX, labelY);
}
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(pad + barPadX, barY, barW, barH);
ctx.fillStyle = 'rgba(0, 255, 200, 0.65)';
ctx.fillRect(pad + barPadX, barY, Math.max(4, barW * decryptPct / 100), barH);
ctx.strokeStyle = 'rgba(0, 234, 255, 0.4)';
ctx.strokeRect(pad + barPadX, barY, barW, barH);
ctx.restore();
}
function useCyberPlayHud() {
return !!(mapData && (isJumpSurvive() || isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()
|| isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay()));
}
/** Cyber HUD: quiz_carry ใช้ playLiveQuizScores (ถูก +QUIZ_CARRY_POINTS_PER_CORRECT ตอนส่งป้ายถูกที่ฮับ) */
function cyberHudQuizCarryPeerScore(peerId) {
if (peerId == null) return 0;
return Math.max(0, Number(playLiveQuizScores[peerId]) || 0);
}
/** แมป mng8a80o ช่วงเล่น: คำถามอยู่บนแผนที่ (โซนทอง) — HUD กลางเหลือ TIME + แผ่นเวลา */
function syncQuizQuestionMissionCyberCenterHud() {
const root = document.getElementById('play-cyber-hud');
const qBand = document.getElementById('play-cyber-quiz-mission-q-band');
const qText = document.getElementById('play-cyber-quiz-mission-q-text');
const qPlaque = document.getElementById('play-cyber-quiz-mission-q-plaque');
const tb = root ? root.querySelector('.play-cyber-time-block') : null;
const on = isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
|| isSpaceShooterMissionHudActivePlay();
if (isQuizQuestionMissionUiMapPlay()) {
if (qBand) {
qBand.classList.add('is-hidden');
qBand.setAttribute('aria-hidden', 'true');
}
if (qText) qText.textContent = '';
if (qPlaque) {
qPlaque.classList.add('is-hidden');
qPlaque.removeAttribute('src');
delete qPlaque.dataset.qmQsrc;
}
}
const plaqueImg = root ? document.getElementById('play-cyber-time-plaque-img') : null;
const head = tb && tb.querySelector ? tb.querySelector('.play-cyber-time-head') : null;
const clearQmTimePlaque = () => {
if (plaqueImg) {
plaqueImg.onload = null;
plaqueImg.onerror = null;
plaqueImg.removeAttribute('src');
plaqueImg.classList.add('is-hidden');
plaqueImg.setAttribute('aria-hidden', 'true');
delete plaqueImg.dataset.qmTimeSrc;
}
if (head) head.classList.remove('play-cyber-time-head--qm-plaque');
if (tb) {
tb.style.backgroundImage = '';
tb.style.backgroundSize = '';
tb.style.backgroundPosition = '';
tb.style.backgroundRepeat = '';
tb.style.paddingTop = '';
tb.style.minWidth = '';
delete tb.dataset.qmTimeSrc;
}
};
if (tb && plaqueImg && head) {
const tUrl = questionMissionHudAssetUrl('time.png');
if (on) {
tb.style.backgroundImage = '';
tb.style.backgroundSize = '';
tb.style.backgroundPosition = '';
tb.style.backgroundRepeat = '';
tb.style.paddingTop = '';
tb.style.minWidth = '';
const applyPlaque = () => {
head.classList.add('play-cyber-time-head--qm-plaque');
plaqueImg.classList.remove('is-hidden');
plaqueImg.setAttribute('aria-hidden', 'false');
};
if (plaqueImg.dataset.qmTimeSrc === tUrl && plaqueImg.getAttribute('src') === tUrl
&& plaqueImg.complete && (plaqueImg.naturalWidth || 0) > 0) {
applyPlaque();
} else if (plaqueImg.dataset.qmTimeSrc !== tUrl || plaqueImg.getAttribute('src') !== tUrl) {
plaqueImg.dataset.qmTimeSrc = tUrl;
plaqueImg.onload = function () {
if ((plaqueImg.naturalWidth || 0) > 0) applyPlaque();
else clearQmTimePlaque();
};
plaqueImg.onerror = function () {
clearQmTimePlaque();
};
plaqueImg.setAttribute('src', tUrl);
}
} else {
clearQmTimePlaque();
}
}
}
function playCyberHudEsc(t) {
return String(t == null ? '' : t)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
/** แถบ Cyber SCORE — ใช้ tint เดียวกับในเกม (ไม่ใช้ PNG รวมซิลูเอตขาวจาก getCharacterImg อย่างเดียว) */
function setCyberHudScoreAvatarImg(avImg, row) {
const cid = row.characterId ? String(row.characterId) : '';
if (!cid) {
avImg.src = defaultAvatarImg.src;
return;
}
const dir = 'down';
const nowT = Date.now();
const walk = false;
const peerId = row.id;
const tint = row.isMe
? (me.playTint || resolvePlayTintForPeer(String(myId != null ? myId : 'me')))
: (() => {
const ox = others.get(peerId);
return ox ? (ox.playTint || resolvePlayTintForPeer(String(peerId))) : resolvePlayTintForPeer(String(peerId));
})();
if (row.isMe) {
const lobbySrc = playLobbyIdleAvatarSrcForCharacter(cid);
if (lobbySrc) {
avImg.src = lobbySrc;
return;
}
}
const cacheKey = [row.isMe ? 'me' : String(peerId), cid, tint.head, tint.hair, tint.body, 'lobbyV3'].join('|');
const comp = resolvePlayDrawCharSource(cid, dir, nowT, walk, tint, !!row.isMe);
if (comp && comp.tagName === 'CANVAS' && comp.width > 0) {
let dataUrl = cyberHudScoreAvatarUrlCache.get(cacheKey);
if (!dataUrl) {
try {
dataUrl = comp.toDataURL('image/png');
} catch (e) {
dataUrl = '';
}
if (dataUrl) {
if (cyberHudScoreAvatarUrlCache.size >= CYBER_HUD_AV_URL_CACHE_CAP) {
const first = cyberHudScoreAvatarUrlCache.keys().next();
if (!first.done) cyberHudScoreAvatarUrlCache.delete(first.value);
}
cyberHudScoreAvatarUrlCache.set(cacheKey, dataUrl);
}
}
if (dataUrl) {
avImg.src = dataUrl;
return;
}
const im = getCharacterImg(cid, dir);
avImg.src = im && im.src ? im.src : defaultAvatarImg.src;
return;
}
if (comp && comp.src) {
avImg.src = comp.src;
} else {
const im = getCharacterImg(cid, dir);
avImg.src = im && im.src ? im.src : defaultAvatarImg.src;
}
}
function quizQuestionMissionJoystickResetVisual() {
quizQuestionMissionJoyVecX = 0;
quizQuestionMissionJoyVecY = 0;
const knob = document.getElementById('quiz-q-mission-joystick-knob');
if (knob) knob.style.transform = 'translate(-50%, -50%)';
}
function quizQuestionMissionJoystickUpdateFromClientXY(clientX, clientY) {
const base = document.getElementById('quiz-q-mission-joystick-base');
const knob = document.getElementById('quiz-q-mission-joystick-knob');
if (!base || !knob) return;
const r = base.getBoundingClientRect();
const cx = r.left + r.width * 0.5;
const cy = r.top + r.height * 0.5;
let dx = clientX - cx;
let dy = clientY - cy;
const half = Math.min(r.width, r.height) * 0.5;
const maxKn = Math.max(18, half * 0.52);
const len = Math.hypot(dx, dy);
if (len > maxKn && len > 1e-6) {
dx = (dx / len) * maxKn;
dy = (dy / len) * maxKn;
}
const nlen = Math.hypot(dx, dy);
const dead = maxKn * 0.12;
if (nlen < dead) {
quizQuestionMissionJoyVecX = 0;
quizQuestionMissionJoyVecY = 0;
knob.style.transform = 'translate(-50%, -50%)';
return;
}
quizQuestionMissionJoyVecX = dx / maxKn;
quizQuestionMissionJoyVecY = dy / maxKn;
knob.style.transform = 'translate(calc(-50% + ' + dx + 'px), calc(-50% + ' + dy + 'px))';
}
function syncQuizQuestionMissionJoystickPlay() {
const root = document.getElementById('quiz-question-mission-joystick');
if (!root) return;
const on = !!(isQuizQuestionMissionHudActivePlay() && !isChatFocused());
root.classList.toggle('is-hidden', !on);
root.setAttribute('aria-hidden', on ? 'false' : 'true');
if (!on) {
const base = document.getElementById('quiz-q-mission-joystick-base');
if (base && quizQuestionMissionJoyPointerId != null) {
try {
base.releasePointerCapture(quizQuestionMissionJoyPointerId);
} catch (_e) { /* ignore */ }
}
quizQuestionMissionJoyPointerId = null;
quizQuestionMissionJoystickResetVisual();
}
}
function syncPlayCyberHud() {
const root = document.getElementById('play-cyber-hud');
const stackEl = document.getElementById('play-canvas-stack');
const on = useCyberPlayHud();
if (root) {
root.classList.toggle('is-hidden', !on);
root.setAttribute('aria-hidden', on ? 'false' : 'true');
if (!on) {
root.classList.remove('play-cyber-hud--question-mission');
root.classList.remove('play-cyber-hud--score-flush-left');
root.classList.remove('play-cyber-hud--jump-mission-hide-score');
root.classList.remove('play-cyber-hud--ss-self-integrity-left');
root.classList.remove('play-cyber-hud--last-light-no-time');
}
}
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;
root.classList.toggle(
'play-cyber-hud--question-mission',
!!(isQuizQuestionMissionHudActivePlay() || isStackTowerMissionHudActivePlay() || isJumpSurviveMissionHudActivePlay()
|| isSpaceShooterMissionHudActivePlay()),
);
root.classList.toggle(
'play-cyber-hud--stack-tower-canvas-hud',
!!(playBotsEnabled() && stackMini && isStack() && isStackTowerMissionUiMapPlay()),
);
root.classList.toggle('play-cyber-hud--ss-self-integrity-left', !!isSpaceShooterMissionHudActivePlay());
/** แถบ SCORE แนวนอน (mno9kb07) — ใช้รูปแบบเดียวกับ quiz_carry / พรีวิว editor */
root.classList.toggle(
'play-cyber-hud--gauntlet-crown-strip',
!!(on && (isGauntletCrownHeistMapPlay() || isQuizCarry())),
);
root.classList.toggle('play-cyber-hud--score-flush-left', !!(on && isQuizCarry()));
root.classList.toggle('play-cyber-hud--jump-mission-hide-score', !!isJumpSurviveMissionUiMapPlay());
/** Last Light mno9kb07: ไม่แสดง TIME / ซูมใต้เวลา — จบด้วยรันเวย์ ไม่ใช้นาฬิกา */
root.classList.toggle('play-cyber-hud--last-light-no-time', !!(on && isGauntletCrownHeistMapPlay()));
const timeLabelEl = root.querySelector('.play-cyber-time-label');
if (timeLabelEl) timeLabelEl.style.display = isQuizCarry() ? 'none' : '';
const timeVal = document.getElementById('play-cyber-time-val');
const timeSub = document.getElementById('play-cyber-time-sub');
const portrait = document.getElementById('play-cyber-portrait-img');
const statusEl = document.getElementById('play-cyber-self-status');
const hintEl = document.getElementById('play-cyber-hint');
const previewEl = document.getElementById('play-cyber-preview-line');
if (timeSub) {
const bbMegaLiveHud = isBalloonBoss() && isMegaVirusMissionShellMapPlay() && !isGauntletCrownPregameBlockingPlay();
timeSub.textContent = isQuizQuestionMissionHudActivePlay()
? (playQuizPhaseLocal === 'read' ? 'READ · อ่านคำถาม'
: (playQuizPhaseLocal === 'answer' ? 'ANSWER · เดินไปโซน จริง / เท็จ'
: 'QUIZ MISSION · ประลองความรู้'))
: (isSpaceShooterMissionHudActivePlay()
? 'SPACE SHOOTER · ARCADE — TIME (sec) · ภารกิจยิงยาน'
: (isStackTowerMissionHudActivePlay()
? 'TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส'
: (isQuizCarry()
? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)'
: (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN'
: (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE'
: (isBalloonBoss() ? (bbMegaLiveHud ? '' : 'BALLOON BOSS · MEGA VIRUS')
: (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))))));
timeSub.style.display = bbMegaLiveHud ? 'none' : '';
}
const zoomHintEl = document.getElementById('play-cyber-embed-zoom-hint');
if (zoomHintEl) {
const showEmbedZoom = !!(previewMode && editorEmbedReturn && mapData && !isQuizQuestionMissionHudActivePlay()
&& !(isBalloonBoss() && isMegaVirusMissionShellMapPlay()));
if (showEmbedZoom) {
const zNum = Number(playEmbedUserZoomMul.toFixed(2));
zoomHintEl.textContent = '×' + String(zNum);
zoomHintEl.classList.remove('is-hidden');
zoomHintEl.setAttribute('aria-hidden', 'false');
} else {
zoomHintEl.textContent = '';
zoomHintEl.classList.add('is-hidden');
zoomHintEl.setAttribute('aria-hidden', 'true');
}
}
if (timeVal) {
if (isQuiz() && isQuizQuestionMissionUiMapPlay()) {
if (quizQuestionMissionPhase !== 'live' || quizQuestionMissionPhase === 'ended') {
timeVal.textContent = '···';
} else if (!playQuizPhaseEndsAt) {
timeVal.textContent = '—';
} else {
timeVal.textContent = String(Math.max(0, Math.ceil((playQuizPhaseEndsAt - Date.now()) / 1000)));
}
} else if (isStack() && isStackTowerMissionUiMapPlay()) {
if (stackTowerMissionPhase === 'live') {
const remSt = Math.max(0, Math.ceil(stackTowerMissionTimeLimitSecPlay() - stackTowerMissionElapsedSecPlay()));
timeVal.textContent = String(remSt);
} else if (stackTowerMissionPhase === 'howto' || stackTowerMissionPhase === 'countdown') {
timeVal.textContent = '···';
} else {
timeVal.textContent = '0';
}
} else if (isQuizCarry()) {
if (quizCarrySessionEnded) {
timeVal.textContent = '0';
} else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedCountdownEndAt > Date.now()) {
const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000));
timeVal.textContent = String(rem);
} else if (quizCarryEmbedMissionFlowActive() && quizCarryEmbedPreOptionCountdownEndAt > Date.now()) {
const rem2 = Math.max(0, Math.ceil((quizCarryEmbedPreOptionCountdownEndAt - Date.now()) / 1000));
timeVal.textContent = String(rem2);
} else if (quizCarryPregameActive) {
timeVal.textContent = '···';
} else if (!quizCarryCurrent) {
timeVal.textContent = '—';
} else if (quizCarryOptionRevealAt > 0 && Date.now() < quizCarryOptionRevealAt) {
timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryOptionRevealAt - Date.now()) / 1000)));
} else if (quizCarryAnswerCloseAt > Date.now()) {
timeVal.textContent = String(Math.max(0, Math.ceil((quizCarryAnswerCloseAt - Date.now()) / 1000)));
} else {
timeVal.textContent = '0';
}
} else if (isGauntlet()) {
if (isGauntletCrownHeistMapPlay() && isGauntletCrownPregameBlockingPlay()) {
timeVal.textContent = '···';
} else if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
const mm = Math.floor(rem / 60);
const ss = rem % 60;
timeVal.textContent = `${mm}:${String(ss).padStart(2, '0')}`;
} else if (gauntletRuntimeTimeLimitSec > 0) {
timeVal.textContent = '···';
} else {
timeVal.textContent = '∞';
}
} else if (isSpaceShooter()) {
if (isSpaceShooterMissionUiMapPlay()) {
if (spaceShooterMissionPhase !== 'live' || spaceShooterGameEnded) {
const limSs = spaceShooterTimeLimitSecPlay();
if (spaceShooterMissionPhase === 'howto' || spaceShooterMissionPhase === 'countdown') {
timeVal.textContent = String(limSs > 0 ? limSs : 0);
} else {
timeVal.textContent = '0';
}
} else {
const remSs = spaceShooterRemainingSecPlay();
timeVal.textContent = remSs != null ? String(remSs) : '∞';
}
} else if (spaceShooterGameEnded) {
timeVal.textContent = '0';
} else {
const rem = spaceShooterRemainingSecPlay();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else if (isBalloonBoss()) {
if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) {
timeVal.textContent = '···';
} else if (balloonBossGameEnded) {
timeVal.textContent = '0';
} else {
const rem = balloonBossRemainingSecPlay();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) {
timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0');
} else {
const rem = jumpSurviveRemainingSecMission();
timeVal.textContent = rem != null ? String(rem) : '∞';
}
} else {
const t = jumpSurviveSessionStartMs > 0
? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000))
: 0;
timeVal.textContent = String(t);
}
}
if (portrait) {
const cid = me.characterId || getPlayCharacterId();
const dir = isSpaceShooter()
? 'down'
: (isGauntletFaceRightMapMno9kb07() ? 'right' : (me.direction || 'down'));
const nowT = Date.now();
const walk = !!me.isWalking;
const rawImg = getAvatarImg(cid, dir, nowT, walk);
const tint = me.playTint || resolvePlayTintForPeer(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 (isSpaceShooterMissionHudActivePlay()) {
ensureStackTowerLifeHudImagesPlay();
const nn = String((me.nickname || nick || 'PILOT')).trim().slice(0, 24);
const hits = Math.max(0, Math.min(SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS, Number(me.spaceShooterHits) || 0));
const rem = SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS - hits;
const esc = playCyberHudEsc(nn);
const W = 220;
const H = 58;
const dpr = Math.min(2, Math.max(1, typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1));
let canv = statusEl.querySelector('canvas.play-cyber-ss-integrity-canvas');
let metaEl = statusEl.querySelector('.play-cyber-ss-meta');
if (!canv || !metaEl) {
statusEl.textContent = '';
canv = document.createElement('canvas');
canv.className = 'play-cyber-ss-integrity-canvas';
canv.setAttribute('aria-hidden', 'true');
metaEl = document.createElement('div');
metaEl.className = 'play-cyber-ss-meta';
statusEl.appendChild(canv);
statusEl.appendChild(metaEl);
}
canv.width = Math.max(1, Math.floor(W * dpr));
canv.height = Math.max(1, Math.floor(H * dpr));
canv.style.width = W + 'px';
canv.style.height = H + 'px';
const ctxSs = canv.getContext('2d');
if (ctxSs) {
ctxSs.setTransform(dpr, 0, 0, dpr, 0, 0);
ctxSs.clearRect(0, 0, W + 2, H + 2);
drawStackTowerLifeIntegrityBarPlay(
ctxSs,
2,
2,
W - 4,
H - 4,
SPACE_SHOOTER_MISSION_MAX_ASTEROID_HITS,
rem,
);
}
metaEl.textContent = 'P1 · ' + esc + ' · TEAM 0 · COMBO x0';
} else if (isQuizQuestionMissionHudActivePlay()) {
statusEl.textContent = 'QUIZ LINK · ประลองความรู้';
} else if (isStackTowerMissionHudActivePlay()) {
statusEl.textContent = 'DECRYPT UPLINK · TOWER';
} else if (isJumpSurviveMissionHudActivePlay()) {
statusEl.textContent = 'JUMPER LINK · SURVIVE';
} else if (isQuizCarry() && quizCarrySessionEnded) {
statusEl.textContent = 'SESSION COMPLETE · จบชุดคำถาม';
} else if (isJumpSurvive() && jumpSurviveEliminated) {
statusEl.textContent = 'SPECTATOR · LINK SEVERED';
} else if (isBalloonBoss() && me.balloonBossEliminated) {
statusEl.textContent = 'SPECTATOR · BALLOONS GONE';
} else if (isGauntletCrownHeistMapPlay()) {
statusEl.textContent = '';
} else {
statusEl.textContent = 'OPERATOR ONLINE';
}
}
if (hintEl) {
if (isQuizQuestionMissionHudActivePlay()) {
hintEl.textContent = '';
hintEl.setAttribute('aria-hidden', 'true');
} else {
hintEl.removeAttribute('aria-hidden');
if (isStackTowerMissionHudActivePlay()) {
hintEl.textContent = 'กด SPACE / ENTER หรือคลิกที่จอ = DROP · วางต่อเนื่องแม่น = โบนัส · ถอดรหัสให้ครบ 100% ภายในเวลา';
} else if (isQuizCarry()) {
hintEl.textContent = quizCarrySessionEnded
? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE'
: ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)');
} else if (isJumpSurvive()) {
if (isJumpSurviveMissionUiMapPlay()) {
hintEl.textContent = jumpSurviveEliminated
? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)'
: 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน';
} else {
hintEl.textContent = jumpSurviveEliminated
? 'Spectate remaining nodes · Exit room to reset session'
: 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)';
}
} else if (isSpaceShooter()) {
hintEl.textContent = spaceShooterGameEnded
? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay'
: 'A D / ← → = move · W S / ↑ ↓ = up/down (lower half of map only) · Space = fire · +5 per hit';
} else if (isBalloonBoss()) {
hintEl.textContent = balloonBossGameEnded
? 'จบแล้ว · Session ended — see overlay'
: (me.balloonBossEliminated
? 'คุณตกรอบแล้ว — ดูเพื่อนเล่น · You are out — spectate'
: (isMegaVirusMissionShellMapPlay()
? 'ลูกศรบนวงหมุนเอง — ยิงตามมุมลูกศร ณ ตอนกด Space · Ring spins · Space = fire along arrow'
: 'ยานมีแรงเฉื่อย — A D / arrows / W S เร่งทิศทาง · ปล่อยปุ่มแล้วยังไหล (แรงหน่วง) · Space = ยิง (ดีเลย์) · ลูกโป้งหมด = ตกรอบ'));
} else if (isGauntletCrownHeistMapPlay()) {
hintEl.textContent = 'Space / W / ↑ jump · Start 100 pts · Hit -10 · Fall off left edge = out · Rank bonus at end';
} else {
hintEl.textContent = 'Space / W / ↑ jump · Lanes + lasers · Clear = right + score';
}
}
}
if (previewEl) {
if (playBotsEnabled() && mapData) {
const human = countPlayHumans();
const bots = [...others.keys()].filter(isPreviewBotId).length;
const botTarget = playBotTargetHeadcount();
const simLabel = detectiveCaseFillBots ? 'CASE' : 'SIM';
previewEl.textContent = `${simLabel} · humans ${human} + bots ${bots} · target ${botTarget}`;
previewEl.classList.remove('is-hidden');
} else {
previewEl.textContent = '';
previewEl.classList.add('is-hidden');
}
}
syncQuizQuestionMissionCyberCenterHud();
const ul = document.getElementById('play-cyber-score-list');
if (!ul) return;
/* mnptfts2 — ไม่แสดงแผง SCORE (เหลือ TIME + โปรไฟล์) */
if (isJumpSurviveMissionUiMapPlay()) {
ul.innerHTML = '';
const boardSkip = ul.closest('.play-cyber-scoreboard');
if (boardSkip) boardSkip.classList.remove('play-cyber-scoreboard--crown-strip');
ul.classList.remove('play-cyber-score-list--crown-strip');
return;
}
const stripScoreHud = isGauntletCrownHeistMapPlay() || isQuizCarry();
const board = ul.closest('.play-cyber-scoreboard');
if (board) board.classList.toggle('play-cyber-scoreboard--crown-strip', !!stripScoreHud);
ul.classList.toggle('play-cyber-score-list--crown-strip', !!stripScoreHud);
const panelTitle = root.querySelector('.play-cyber-panel-title');
const crownHead = document.getElementById('play-cyber-crown-score-head');
const crownImg = document.getElementById('play-cyber-crown-score-img');
if (panelTitle && crownHead && crownImg) {
if (stripScoreHud) {
crownHead.classList.add('is-hidden');
crownHead.setAttribute('aria-hidden', 'true');
crownImg.removeAttribute('src');
delete crownImg.dataset.gauntletScoreSrc;
crownImg.alt = '';
crownImg.onerror = null;
crownImg.onload = null;
panelTitle.textContent = 'SCORE :';
panelTitle.classList.remove('is-hidden');
} else {
crownHead.classList.add('is-hidden');
crownHead.setAttribute('aria-hidden', 'true');
crownImg.removeAttribute('src');
delete crownImg.dataset.gauntletScoreSrc;
crownImg.alt = '';
crownImg.onerror = null;
crownImg.onload = null;
panelTitle.textContent = 'SCORE';
panelTitle.classList.remove('is-hidden');
}
}
const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay();
const quizMissionHud = isQuizQuestionMissionHudActivePlay();
const stackMissionHud = isStackTowerMissionHudActivePlay();
const jumpMissionLiveHud = isJumpSurviveMissionHudActivePlay();
const spaceMissionLiveHud = isSpaceShooterMissionHudActivePlay();
/** แถบ SCORE แบบ mock mng8a80o (แนวนอน อวาตาร์+ชื่อ | คะแนน) — ภารกิจคำถาม + Stack Tower + Jumper mnptfts2 + Space mnpz6rkp */
const cyberQmMockHud = quizMissionHud || stackMissionHud || jumpMissionLiveHud || spaceMissionLiveHud;
const stackTeamPts = Math.max(0, Number(stackMini && stackMini.score) || 0);
const rows = [];
rows.push({
id: myId,
nickname: me.nickname || nick,
characterId: me.characterId || getPlayCharacterId(),
score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(myId)
: (stackMissionHud
? (playBotsEnabled() ? (me.stackPreviewHumanPts || 0) : stackTeamPts)
: (isGauntlet() ? (me.gauntletScore || 0)
: (isSpaceShooter() ? (me.spaceShooterScore || 0)
: (isBalloonBoss() ? (me.balloonBossScore || 0)
: (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0))))),
y: safeYv(me.y),
eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated)
|| !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated),
isMe: true,
});
others.forEach((o, id) => {
rows.push({
id,
nickname: o.nickname || id.slice(0, 8),
characterId: o.characterId,
score: (isQuizCarry() || quizMissionHud) ? cyberHudQuizCarryPeerScore(id)
: (stackMissionHud
? (playBotsEnabled() && isPreviewBotId(id) ? (o.stackBotScore || 0) : stackTeamPts)
: (isGauntlet() ? (o.gauntletScore || 0)
: (isSpaceShooter() ? (o.spaceShooterScore || 0)
: (isBalloonBoss() ? (o.balloonBossScore || 0)
: (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0))))),
y: safeYv(o.y),
eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated)
|| !!(isBalloonBoss() && o.balloonBossEliminated)
|| !!(isGauntletCrownHeistMapPlay() && o.gauntletEliminated),
isMe: false,
});
});
let leaderId = null;
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
let mx = -1;
for (let i = 0; i < rows.length; i++) {
if (rows[i].score > mx) {
mx = rows[i].score;
leaderId = rows[i].id;
}
}
} else {
let best = Infinity;
for (let i = 0; i < rows.length; i++) {
if (!rows[i].eliminated && rows[i].y < best) {
best = rows[i].y;
leaderId = rows[i].id;
}
}
}
rows.sort((a, b) => {
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
if (b.score !== a.score) return b.score - a.score;
if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th');
}
if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1;
return a.y - b.y;
});
ul.innerHTML = '';
const cap = stripScoreHud ? (isQuizCarry() ? 8 : 6) : 8;
let hudRows = rows.slice(0, Math.min(cap, rows.length));
if (stripScoreHud) {
const mi = hudRows.findIndex((row) => row.isMe);
if (mi >= 0) {
const mine = hudRows.splice(mi, 1)[0];
hudRows.push(mine);
}
}
for (let r = 0; r < hudRows.length; r++) {
const row = hudRows[r];
const li = document.createElement('li');
li.className = 'play-cyber-score-row' + (row.isMe ? ' is-me' : '') + (row.eliminated ? ' is-out' : '')
+ (cyberQmMockHud ? ' play-cyber-score-row--qm-mock' : '')
+ (stripScoreHud ? ' play-cyber-score-row--crown-strip' : '');
const av = document.createElement('img');
av.className = 'play-cyber-score-av' + (cyberQmMockHud ? ' play-cyber-score-av--qm' : '')
+ (stripScoreHud ? ' play-cyber-score-av--crown-strip' : '');
av.alt = '';
setCyberHudScoreAvatarImg(av, row);
const sc = document.createElement('span');
sc.className = 'play-cyber-score-val' + (cyberQmMockHud ? ' play-cyber-qm-score' : '')
+ (stripScoreHud ? ' play-cyber-crown-strip-val' : '');
if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud || quizMissionHud || stackMissionHud) {
sc.textContent = String(row.score);
} else {
sc.textContent = row.eliminated ? '—' : String(r + 1);
}
if (cyberQmMockHud) {
if (row.id === leaderId && leaderId != null) {
const crown = document.createElement('span');
crown.className = 'play-cyber-lead-crown';
crown.textContent = '♔';
crown.title = 'Leader';
sc.insertBefore(crown, sc.firstChild);
}
const qmLeft = document.createElement('div');
qmLeft.className = 'play-cyber-qm-left';
const avWrap = document.createElement('span');
avWrap.className = 'play-cyber-qm-av-wrap';
avWrap.appendChild(av);
const qmName = document.createElement('span');
qmName.className = 'play-cyber-qm-name';
qmName.textContent = String(row.nickname || '').trim().slice(0, 28);
qmLeft.appendChild(avWrap);
qmLeft.appendChild(qmName);
li.appendChild(qmLeft);
li.appendChild(sc);
} else if (stripScoreHud) {
const cell = document.createElement('div');
cell.className = 'play-cyber-crown-strip-cell';
const avWrap = document.createElement('div');
avWrap.className = 'play-cyber-crown-strip-av-wrap';
avWrap.appendChild(av);
const meta = document.createElement('div');
meta.className = 'play-cyber-crown-strip-meta';
const name = document.createElement('span');
name.className = 'play-cyber-crown-strip-name';
if (row.id === leaderId && leaderId != null) {
const crown = document.createElement('span');
crown.className = 'play-cyber-lead-crown';
crown.textContent = '♔';
crown.title = 'Leader';
name.appendChild(crown);
}
name.appendChild(document.createTextNode(String(row.nickname || '').trim().slice(0, 16)));
meta.appendChild(name);
meta.appendChild(sc);
cell.appendChild(avWrap);
cell.appendChild(meta);
li.appendChild(cell);
} else {
const mid = document.createElement('div');
mid.className = 'play-cyber-score-mid';
const name = document.createElement('span');
name.className = 'play-cyber-score-name';
if (row.id === leaderId && leaderId != null) {
const crown = document.createElement('span');
crown.className = 'play-cyber-lead-crown';
crown.textContent = '♔';
crown.title = 'Leader';
name.appendChild(crown);
}
name.appendChild(document.createTextNode(String(row.nickname || '').slice(0, 14)));
const st = document.createElement('span');
st.className = 'play-cyber-score-state';
st.textContent = (isQuizCarry() || quizMissionHud)
? ''
: (row.eliminated
? (isBalloonBoss() ? 'OUT' : (isGauntletCrownHeistMapPlay() ? 'เสียชีวิต' : 'OFFLINE'))
: (isBalloonBoss()
? ('♥' + Math.max(0, row.isMe ? (me.balloonBossBalloons | 0) : ((() => {
const ox = others.get(row.id);
return ox ? (ox.balloonBossBalloons | 0) : 0;
})())))
: 'LINK_OK'));
mid.appendChild(name);
mid.appendChild(st);
li.appendChild(av);
li.appendChild(mid);
li.appendChild(sc);
}
ul.appendChild(li);
}
}
function draw() {
try {
if (!mapData) {
document.documentElement.classList.remove('play-stack-tower-pixel-canvas');
return;
}
document.documentElement.classList.toggle('play-stack-tower-pixel-canvas', isStackTowerMissionUiMapPlay());
} catch (e) { /* ignore */ }
if (isGauntletCrownHeistMapPlay()) ensureGauntletCrownScorePenaltyImgPlay();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const w = mapData.width, h = mapData.height;
let zDraw = computePlayCameraZDrawPlay();
const stackCam = isStack() ? getStackCameraCentersPx() : null;
let camX = stackCam ? stackCam.px : me.x * tileSize;
let camY = stackCam ? stackCam.py : me.y * tileSize;
if (isJumpSurvive()) {
camX = jumpSurviveCamCenterX;
camY = jumpSurviveCamCenterY;
}
if (isSpaceShooter() || isBalloonBoss()) {
const mwPx = w * tileSize, mhPx = h * tileSize;
camX = mwPx * 0.5;
camY = mhPx * 0.5;
} else if (isQuizCarry()) {
const qc = getQuizCarryMapCameraWorldCenterPxPlay();
if (qc) {
camX = qc.cx;
camY = qc.cy;
}
} else if (isQuizQuestionMissionHudActivePlay()) {
const qmc = getQuizQuestionMissionMapCenterWorldPxPlay();
if (qmc) {
camX = qmc.cx;
camY = qmc.cy;
}
}
const gauntletGroupCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zDraw);
if (gauntletGroupCam) {
camX = gauntletGroupCam.px;
camY = gauntletGroupCam.py;
}
/* Stack Tower (mnn93hpi): ไม่เลื่อนกล้องตาม floorWorldY — ใช้ cam จาก getStackCameraCentersPx() เหมือน Stack ทั่วไป (วางบล็อกแล้วจอไม่ไหล) */
lastPlayZDrawForInput = zDraw;
const stackTowerWorldScrollScreenY = getStackTowerWorldLayerScrollScreenOffsetYPlay(zDraw);
const halfW = canvas.width / (2 * zDraw);
const halfH = canvas.height / (2 * zDraw);
// world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map)
const worldMinX = camX - halfW;
const worldMaxX = camX + halfW;
const worldMinY = camY - halfH;
const worldMaxY = camY + halfH;
const mapWpx = w * tileSize, mapHpx = h * tileSize;
const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY;
const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false';
const timeMs = Date.now();
if (playScrollBgDrawActive()) {
ctx.fillStyle = '#0a0e22';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawPlayScrollBgFullCanvas(canvas.width, canvas.height);
} else if (gauntletCrownRunwayBgDrawActivePlay()) {
ctx.fillStyle = '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGauntletCrownRunwayBgFullCanvasPlay(canvas.width, canvas.height, {
worldMinX,
worldMaxX,
camX,
camY,
zDraw: zDraw,
mapWpx,
mapHpx,
});
} else if (stackTowerScrollBgDrawActive()) {
ctx.fillStyle = '#0a0e22';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawStackTowerScrollBgFullCanvas(canvas.width, canvas.height, zDraw);
} else if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) {
/* กล้องเห็นพื้นที่นอก [0,map] ได้ — ห้ามสุ่มต้นทาง drawImage เกินขอบภาพ (จะเกิดซ้ำ/เส้นตัด/ซ้อนกันที่ขอบจอ) */
ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawStackTowerLoopSkyFillAboveMap(ctx, worldMinY, zDraw, canvas.width, canvas.height);
const natW = mapBackgroundImg.naturalWidth;
const natH = mapBackgroundImg.naturalHeight;
const bgMinX = Math.max(0, worldMinX);
const bgMaxX = Math.min(mapWpx, worldMaxX);
const bgMinY = Math.max(0, worldMinY);
const bgMaxY = Math.min(mapHpx, worldMaxY);
if (bgMaxX > bgMinX && bgMaxY > bgMinY) {
const srcX = (bgMinX / mapWpx) * natW;
const srcY = (bgMinY / mapHpx) * natH;
const srcW = ((bgMaxX - bgMinX) / mapWpx) * natW;
const srcH = ((bgMaxY - bgMinY) / mapHpx) * natH;
const destX = (bgMinX - camX) * zDraw + canvas.width / 2;
const destY = (bgMinY - camY) * zDraw + canvas.height / 2
- (isStackTowerMissionUiMapPlay() ? stackTowerWorldScrollScreenY : 0);
const destW = (bgMaxX - bgMinX) * zDraw;
const destH = (bgMaxY - bgMinY) * zDraw;
if (srcW > 0.25 && srcH > 0.25 && destW > 0.25 && destH > 0.25) {
ctx.drawImage(mapBackgroundImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
}
}
} else {
ctx.fillStyle = (isSpaceShooter() || isBalloonBoss()) ? '#0a0e22' : '#1a1b26';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (isSpaceShooter()) {
ctx.save();
for (let s = 0; s < 120; s++) {
const sx = (Math.sin(s * 12.9898 + w) * 0.5 + 0.5) * canvas.width;
const sy = (Math.cos(s * 4.1415 + h) * 0.5 + 0.5) * canvas.height;
const br = (s % 4 === 0) ? 1.6 : 0.9;
ctx.fillStyle = s % 7 === 0 ? 'rgba(200, 240, 255, 0.35)' : 'rgba(255, 255, 255, 0.12)';
ctx.beginPath();
ctx.arc(sx, sy, br, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (isBalloonBoss()) {
ctx.save();
ctx.fillStyle = 'rgba(30, 45, 120, 0.25)';
for (let col = 0; col < 24; col++) {
const x = (col + 0.5) * (canvas.width / 24);
for (let r = 0; r < 18; r++) {
const bit = (col * 17 + r * 31 + Math.floor(timeMs / 80)) % 2;
ctx.fillStyle = bit ? 'rgba(0, 255, 200, 0.14)' : 'rgba(180, 200, 255, 0.08)';
ctx.fillRect(x - 1, (r / 18) * canvas.height + ((timeMs / 40 + col * 3) % 40), 2.2, 10);
}
}
ctx.fillStyle = 'rgba(240, 248, 255, 0.55)';
ctx.beginPath();
ctx.moveTo(0, canvas.height);
const baseY = canvas.height * 0.88;
for (let k = 0; k <= 40; k++) {
const bx = (k / 40) * canvas.width;
const by = baseY - Math.abs(Math.sin(k * 1.7 + w)) * canvas.height * 0.08 - (k % 3) * 4;
ctx.lineTo(bx, by);
}
ctx.lineTo(canvas.width, canvas.height);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
function worldToScreen(wx, wy) {
const sx = (wx - camX) * zDraw + canvas.width / 2;
const sy = (wy - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
return [sx, sy];
}
function drawGridImagePlacementsLayer() {
if (!mapData.gridImagePlacements || !mapData.gridImagePlacements.length || !mapData.gridImageLibrary || !mapData.gridImageLibrary.length) return;
for (let gi = 0; gi < mapData.gridImagePlacements.length; gi++) {
const sp = mapData.gridImagePlacements[gi];
const wx0 = sp.x * tileSize;
const wy0 = sp.y * tileSize;
const ww = sp.w * tileSize;
const wh = sp.h * tileSize;
if (wx0 + ww <= worldMinX || wx0 >= worldMaxX || wy0 + wh <= worldMinY || wy0 >= worldMaxY) continue;
let gim = mapGridImageImgs[sp.i];
let carrySpriteAlpha = 1;
if (isQuizCarry()) {
const domOpt = quizCarryOptionIndexForGridSprite(mapData, sp);
const onHub = spriteOverlapsQuizCarryHubArea(mapData, sp);
const meHolding = me && me.quizCarryHeld != null;
let carryVisualActive = false;
if (domOpt != null && quizCarryOptionHeldByAnyone(domOpt)) carryVisualActive = true;
else if (domOpt == null && onHub && meHolding) carryVisualActive = true;
if (carryVisualActive) {
const gh = mapGridImageHeldImgs[sp.i];
if (gh && gh.complete && gh.naturalWidth) gim = gh;
else carrySpriteAlpha = 0.32;
}
}
if (!gim || !gim.complete || !gim.naturalWidth) continue;
const sx0 = (wx0 - camX) * zDraw + canvas.width / 2;
const sy0 = (wy0 - camY) * zDraw + canvas.height / 2 - stackTowerWorldScrollScreenY;
const sw = ww * zDraw;
const sh = wh * zDraw;
ctx.save();
if (carrySpriteAlpha < 1) ctx.globalAlpha = carrySpriteAlpha;
ctx.beginPath();
ctx.rect(sx0, sy0, sw, sh);
ctx.clip();
ctx.drawImage(gim, sx0, sy0, sw, sh);
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.strokeRect(sx0 + 0.5, sy0 + 0.5, sw - 1, sh - 1);
}
}
const startTileX = Math.max(0, Math.floor(worldMinX / tileSize));
const endTileX = Math.min(w - 1, Math.ceil(worldMaxX / tileSize));
const startTileY = Math.max(0, Math.floor(worldMinY / tileSize));
const endTileY = Math.min(h - 1, Math.ceil(worldMaxY / tileSize));
if (showGrid) {
for (let y = startTileY; y <= endTileY; y++) {
const lane = isFrogger() ? getLane(y) : null;
let rowFill = null;
if (lane) {
if (lane.type === 'goal') rowFill = 'rgba(158,206,106,0.4)';
else if (lane.type === 'spawn') rowFill = 'rgba(187,154,247,0.35)';
else if (lane.type === 'road') rowFill = 'rgba(80,70,60,0.6)';
else if (lane.type === 'water') rowFill = 'rgba(125,207,255,0.5)';
} else if (isGauntlet()) {
rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.08)' : 'rgba(180,90,140,0.06)';
} else if (isStack()) {
rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.06)' : 'rgba(187, 154, 247, 0.05)';
} else if (isJumpSurvive()) {
rowFill = (y % 2 === 0) ? 'rgba(90, 200, 255, 0.07)' : 'rgba(60, 140, 200, 0.06)';
} else if (isSpaceShooter()) {
rowFill = (y % 2 === 0) ? 'rgba(30, 45, 110, 0.22)' : 'rgba(20, 32, 78, 0.28)';
} else if (isBalloonBoss()) {
rowFill = (y % 2 === 0) ? 'rgba(40, 25, 80, 0.2)' : 'rgba(25, 18, 60, 0.24)';
} else if (isQuizBattle()) {
rowFill = (y % 2 === 0) ? 'rgba(110, 175, 255, 0.06)' : 'rgba(230, 110, 140, 0.055)';
}
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw;
const ob = mapData.objects?.[y]?.[x] ?? 0;
if (ob === 1) {
ctx.fillStyle = 'rgba(65,72,104,0.92)';
ctx.fillRect(sx, sy, size, size);
ctx.strokeStyle = '#565f89';
ctx.strokeRect(sx, sy, size, size);
} else {
const cellColor = showGrid && mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x];
if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(sx, sy, size, size); }
else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(sx, sy, size, size); }
else if (!mapBackgroundImg || !mapBackgroundImg.complete) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335';
ctx.fillRect(sx, sy, size, size);
}
}
}
}
}
drawGridImagePlacementsLayer();
if (showGrid) {
for (let y = startTileY; y <= endTileY; y++) {
for (let x = startTileX; x <= endTileX; x++) {
const wx = x * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw;
const isInter = mapData.interactive && mapData.interactive[y] && mapData.interactive[y][x] === 1;
if (isInter) {
ctx.fillStyle = 'rgba(158,206,106,0.35)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(158,206,106,0.8)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
if (isQuiz()) {
const isQuizQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isQuizQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.32)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizT = mapData.quizTrueArea && mapData.quizTrueArea[y] && mapData.quizTrueArea[y][x] === 1;
if (isQuizT) {
ctx.fillStyle = 'rgba(86, 202, 255, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isQuizF = mapData.quizFalseArea && mapData.quizFalseArea[y] && mapData.quizFalseArea[y][x] === 1;
if (isQuizF) {
ctx.fillStyle = 'rgba(247, 118, 190, 0.38)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
}
if (isQuizCarry()) {
const hub = mapData.quizCarryHubArea && mapData.quizCarryHubArea[y] && mapData.quizCarryHubArea[y][x] === 1;
if (hub) {
ctx.fillStyle = 'rgba(187, 154, 247, 0.35)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(200, 170, 255, 0.75)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const isCarryQ = mapData.quizQuestionArea && mapData.quizQuestionArea[y] && mapData.quizQuestionArea[y][x] === 1;
if (isCarryQ) {
ctx.fillStyle = 'rgba(255, 214, 102, 0.3)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(224, 185, 70, 0.82)';
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
}
const ov = mapData.quizCarryOptionArea && mapData.quizCarryOptionArea[y] && mapData.quizCarryOptionArea[y][x];
if (ov >= 1 && ov <= QUIZ_CARRY_MAX_OPTION_SLOTS) {
ctx.fillStyle = quizCarryMinimapOptionFillCss(ov);
ctx.fillRect(sx + 3, sy + 3, size - 6, size - 6);
}
}
if (isQuizBattle() && showGrid && mapData.quizBattlePathArea && mapData.quizBattlePathArea[y] && mapData.quizBattlePathArea[y][x] === 1) {
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);
}
}
/* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */
if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
if (sv >= 1 && sv <= 6) {
ctx.fillStyle = 'rgba(0, 255, 240, 0.14)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(120, 255, 255, 0.45)';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(220, 250, 255, 0.9)';
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
ctx.textAlign = 'left';
}
}
}
}
}
if (isJumpSurvive() && mapData.jumpSurvivePlatformArea) {
const pa = mapData.jumpSurvivePlatformArea;
const sc = jumpSurvivePlatformScrollPx;
const tsz = tileSize;
const period = h * tsz;
const visTop = worldMinY;
const visBot = worldMaxY;
const eps = 1e-6;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
if (!pa[py] || pa[py][px] !== 1) continue;
const vIdx = jumpSurvivePlatformVariantIndexAtPlay(mapData, px, py);
if (px > 0 && pa[py][px - 1] === 1 && jumpSurvivePlatformVariantIndexAtPlay(mapData, px - 1, py) === vIdx) {
continue;
}
let runLen = 1;
while (px + runLen < w && pa[py][px + runLen] === 1
&& jumpSurvivePlatformVariantIndexAtPlay(mapData, px + runLen, py) === vIdx) runLen += 1;
const cfg = playJumpSurvivePlatformTiles[vIdx - 1] || { url: '', w: 0, h: 0 };
const jumpPlatU = normalizeGauntletAssetUrlForPlay(cfg.url || '');
const jumpPlatRec = jumpPlatU ? ensureGauntletAssetImage(jumpPlatU) : null;
const jumpPlatImg = jumpPlatRec && jumpPlatRec.ready && jumpPlatRec.img && jumpPlatRec.img.naturalWidth > 0 ? jumpPlatRec.img : null;
const tileWorldLeft = px * tsz;
const tileWorldRight = (px + runLen) * tsz;
if (tileWorldRight <= worldMinX || tileWorldLeft >= worldMaxX) continue;
const rawTop = py * tsz + sc;
const kMin = Math.ceil((visTop - rawTop - tsz + eps) / period);
const kMax = Math.floor((visBot - rawTop - eps) / period);
if (kMax < kMin) continue;
for (let kk = kMin; kk <= kMax; kk++) {
const platTop = rawTop + kk * period;
if (platTop + tsz <= visTop || platTop >= visBot) continue;
const [psx, psy] = worldToScreen(tileWorldLeft, platTop);
const psz = tsz * zDraw;
const segScreenW = runLen * psz;
let dw;
let dh = cfg.h > 0 ? cfg.h * zDraw : psz - 4;
if (runLen > 1) {
dw = segScreenW;
} else {
dw = cfg.w > 0 ? cfg.w * zDraw : psz - 4;
const maxDim = psz * 4;
if (dw > maxDim) dw = maxDim;
}
const maxDh = psz * 4;
if (dh > maxDh) dh = maxDh;
const marginBot = 2;
const dx = runLen > 1 ? psx : psx + (psz - dw) / 2;
const dy = psy + psz - dh - marginBot;
if (jumpPlatImg) {
ctx.save();
try { ctx.imageSmoothingEnabled = true; } catch (e) { /* ignore */ }
ctx.drawImage(jumpPlatImg, dx, dy, dw, dh);
ctx.restore();
} else {
const fillW = runLen > 1 ? segScreenW - 6 : psz - 6;
ctx.fillStyle = 'rgba(0, 40, 52, 0.72)';
ctx.fillRect(psx + 3, psy + 3, fillW, psz - 6);
const grad = ctx.createLinearGradient(psx, psy, psx, psy + psz);
grad.addColorStop(0, 'rgba(0, 255, 240, 0.12)');
grad.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
grad.addColorStop(1, 'rgba(0, 180, 200, 0.08)');
ctx.fillStyle = grad;
ctx.fillRect(psx + 4, psy + 4, fillW - 2, psz - 8);
ctx.lineWidth = 1;
if (zDraw >= 1.15 && psz > 22) {
ctx.save();
ctx.font = `600 ${Math.max(7, Math.min(11, psz * 0.14))}px "Share Tech Mono", ui-monospace, monospace`;
ctx.fillStyle = 'rgba(180, 255, 255, 0.5)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const cx = psx + (runLen > 1 ? segScreenW * 0.5 : psz * 0.5);
ctx.fillText('DATA', cx, psy + psz * 0.38);
ctx.font = `500 ${Math.max(5, Math.min(8, psz * 0.1))}px "Share Tech Mono", ui-monospace, monospace`;
ctx.fillStyle = 'rgba(120, 230, 255, 0.38)';
ctx.fillText('/SEC/BLOCK', cx, psy + psz * 0.62);
ctx.restore();
}
}
}
}
}
}
if (isJumpSurvive() && showGrid && mapData.jumpSurviveHazardArea) {
const ha = mapData.jumpSurviveHazardArea;
const tsz = tileSize;
for (let py = startTileY; py <= endTileY; py++) {
for (let px = startTileX; px <= endTileX; px++) {
if (!ha[py] || ha[py][px] !== 1) continue;
const [psx, psy] = worldToScreen(px * tsz, py * tsz);
const psz = tsz * zDraw;
ctx.fillStyle = 'rgba(200, 28, 55, 0.38)';
ctx.fillRect(psx + 1, psy + 1, psz - 2, psz - 2);
ctx.strokeStyle = 'rgba(255, 90, 110, 0.82)';
ctx.lineWidth = 2;
ctx.strokeRect(psx + 1, psy + 1, psz - 2, psz - 2);
ctx.lineWidth = 1;
}
}
}
if (showGrid) {
if (isFrogger() && mapData.lanes) {
for (let y = startTileY; y <= endTileY; y++) {
const lane = getLane(y);
if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue;
const positions = getVehiclePositions(lane, mapData.width, timeMs);
const isRoad = lane.type === 'road';
for (let i = 0; i < positions.length; i++) {
const vx = positions[i];
const wx = vx * tileSize, wy = y * tileSize;
const [sx, sy] = worldToScreen(wx, wy);
const size = tileSize * zDraw * (isRoad ? 1.2 : 1.5);
ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355';
ctx.fillRect(sx, sy, size, size * 0.7);
ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a';
ctx.strokeRect(sx, sy, size, size * 0.7);
}
}
}
}
if (isQuizCarry()) {
drawQuizCarryChoiceLabels(ctx, worldToScreen, zDraw);
}
if (isStack() && stackMini) {
drawStackMinigame(ctx, worldToScreen, zDraw);
}
const gauntletObsDraw = (isGauntlet() && !gauntletCrownRunwayBgHideObstaclesPlay())
? 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, crownPenaltyFxUntil) {
const { cw, ch } = getCharacterFootprintWH(mapData);
const air = Number(gauntletAirTicks) || 0;
let liftWorldY = 0;
if (isGauntlet() && air > 0) {
liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
}
const cxWorld = (ax + cw * 0.5) * tileSize;
/* ใช้ ay แบบต่อเนื่อง — ห้าม floor(ay) เดิมทำให้เท้ากระโดดทีละช่องขณะเดิน (กระตุกแนวตั้ง) */
const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
const cellSpan = Math.max(cw, ch);
const boxW = tileSize * zDraw * Math.max(cw, 1) * 1.15;
const boxH = tileSize * zDraw * Math.max(ch, 1) * 1.35;
const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
const dir = direction || 'down';
const charImg = resolvePlayDrawCharSource(characterId, dir, timeMs, isWalking, playTint, !!isMe);
/* รองรับทั้ง
และ canvas จากย้อมสี — กล่อง W×H ตาม footprint; อนุญาต upscale เลเยอร์เล็ก (ห้ามจำกัด scale≤1 ไม่งั้นตัวหดเป็นจุด) */
let iw = 0, ih = 0;
if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) {
iw = charImg.width;
ih = charImg.height;
} else if (charImg && charImg.complete && charImg.naturalWidth > 0 && charImg.naturalHeight > 0) {
iw = charImg.naturalWidth;
ih = charImg.naturalHeight;
}
let halfSpriteW = r;
let spriteTopY = sy - 2 * r;
if (iw > 0 && ih > 0) {
const scale = Math.min(boxW / iw, boxH / ih);
let drawW = iw * scale;
let drawH = ih * scale;
if (isQuiz()) {
const minPx = Math.max(28, r * 1.85);
if (drawW < minPx || drawH < minPx) {
const up = Math.max(minPx / Math.max(drawW, 0.01), minPx / Math.max(drawH, 0.01));
drawW *= up;
drawH *= up;
}
}
halfSpriteW = drawW / 2;
spriteTopY = sy - drawH;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH);
} else {
const cy = sy - r;
ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a';
ctx.beginPath();
ctx.arc(sx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#c0caf5';
ctx.lineWidth = 2;
ctx.stroke();
spriteTopY = cy - r;
}
ctx.fillStyle = '#c0caf5';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const nameBase = name || '';
const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0
? (nameBase + ' · ' + gauntletScoreLabel)
: nameBase;
ctx.fillText(withScore, sx, sy + 10);
if (isGauntletCrownHeistMapPlay()
&& typeof crownPenaltyFxUntil === 'number'
&& crownPenaltyFxUntil > timeMs) {
const penImg = ensureGauntletCrownScorePenaltyImgPlay();
if (penImg && penImg.complete && penImg.naturalWidth > 0) {
const ref = Math.max(18, r * 2);
const scale = Math.min(1.15, ref / Math.max(penImg.naturalWidth, penImg.naturalHeight, 1));
const dw = penImg.naturalWidth * scale;
const dh = penImg.naturalHeight * scale;
ctx.drawImage(penImg, 0, 0, penImg.naturalWidth, penImg.naturalHeight, sx - dw * 0.5, spriteTopY - dh - 5, dw, dh);
}
}
if (isQuizCarry() && quizCarrySignPayload) {
drawQuizCarryHeldSignBoard(
sx, sy, halfSpriteW, spriteTopY,
quizCarrySignPayload.text, dir, quizCarrySignPayload.choiceIndex,
quizCarrySignPayload.imageUrl,
quizCarrySignPayload.imageElement,
);
}
}
function quizCarrySignForEntity(ent) {
if (!isQuizCarry() || !quizCarryCurrent || !ent || ent.quizCarryHeld == null) return null;
const idx = ent.quizCarryHeld;
const ch = quizCarryCurrent.choices;
if (!Array.isArray(ch) || idx < 0 || idx >= ch.length) return null;
const t = String(ch[idx] != null ? ch[idx] : '').trim();
const imgUrl = getQuizCarryPlaqueImageUrlForIndex(quizCarryCurrent, idx);
const gridHeldEl = findQuizCarryGridHeldImgForChoiceIndex(idx);
if (!t && !imgUrl && !gridHeldEl) return null;
return { text: t, choiceIndex: idx, imageUrl: imgUrl, imageElement: gridHeldEl || undefined };
}
const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1);
function peerVisualOffset(id) {
let h = 0;
for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0;
return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 };
}
function shouldGauntletCrownHeistSkipAvatarDrawPlay(ent, isMeFlag, offAx, offAy) {
if (!isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'live') return false;
if (!ent || ent.gauntletEliminated) return true;
const { cw, ch } = getCharacterFootprintWH(mapData);
const ax = safeX(ent.x) + (offAx || 0);
const ay = safeY(ent.y) + (offAy || 0);
const airTicks = isMeFlag ? (meGauntletJumpTicks || 0) : (Number(ent.gauntletJumpTicks) || 0);
const airVis = isMeFlag ? (meGauntletJumpVis || 0) : (Number(ent.gauntletJumpVis) || 0);
const air = airVis > 0.08 ? airVis : airTicks;
let liftWorldY = 0;
if (isGauntlet() && air > 0) {
liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52;
}
const cxWorld = (ax + cw * 0.5) * tileSize;
const cyBottomWorld = (ay + ch) * tileSize - liftWorldY;
const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld);
const cellSpan = Math.max(cw, ch);
const r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2);
const pad = Math.max(28, r * 0.4);
if (sx + r + pad < 0 || sx - r - pad > canvas.width) return true;
if (sy + 52 < 0 || sy - r * 4 - pad > canvas.height) return true;
return false;
}
const { ch: sortCh } = getCharacterFootprintWH(mapData);
/** ใช้แถวกริด (floor y) เป็นหลัก — ลดการสลับลำดับวาดทุกเฟรมเมื่อ y เป็น float เลอร์ป */
function avatarDrawDepth(ent) {
return Math.floor(safeY(ent.y)) + sortCh;
}
if (!isStack() && !isSpaceShooter() && !isBalloonBoss()) {
let mt = me.playTint;
if (!mt || typeof mt.head !== 'string' || typeof mt.hair !== 'string' || typeof mt.body !== 'string') {
mt = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')));
me.playTint = mt;
}
const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : (isJumpSurvive() && jumpSurviveEliminated ? ' (ตกรอบ)' : ' (คุณ)'));
const crownRunStripLive = isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && gauntletCrownRunwayAvatarRunAllowedPlay();
const meWalking = isFrogger()
? false
: (isGauntlet()
? ((crownRunStripLive && !me.gauntletEliminated) || meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08)
: !!me.isWalking);
const drawRows = [];
others.forEach((o, id) => {
if (!o) return;
drawRows.push({ isMe: false, id, o });
});
drawRows.push({ isMe: true, id: '__me', o: me });
drawRows.sort((a, b) => {
const da = avatarDrawDepth(a.o);
const db = avatarDrawDepth(b.o);
if (da !== db) return da - db;
const xa = Math.floor(safeX(a.o.x) * 4);
const xb = Math.floor(safeX(b.o.x) * 4);
if (xa !== xb) return xa - xb;
const sa = a.isMe ? '\uFFFFme' : String(a.id);
const sb = b.isMe ? '\uFFFFme' : String(b.id);
return sa < sb ? -1 : sa > sb ? 1 : 0;
});
drawRows.forEach((row) => {
if (!row.isMe) {
const id = row.id;
const o = row.o;
const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id);
const otherWalk = isGauntlet()
? ((crownRunStripLive && !o.gauntletEliminated) || (o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08)
: (isPreviewBotId(id)
? !!o.botIsWalking
: !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02)));
const ot = o.playTint || resolvePlayTintForPeer(id);
const faceDirOther = isGauntletFaceRightMapMno9kb07() ? 'right' : o.direction;
const botOut = isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated;
const peerName = botOut ? (o.nickname + ' (ตกรอบ)') : o.nickname;
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(o, false, off.ax, off.ay)) return;
const axO = safeX(o.x) + off.ax;
const ayO = safeY(o.y) + off.ay;
if (playQuizAvatarHideWhileOverlappingAnswerZone(o, id, axO, ayO)) return;
if (botOut) ctx.save();
if (botOut) ctx.globalAlpha = 0.4;
drawAvatar(axO, ayO, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o), isGauntletCrownHeistMapPlay() ? (o.gauntletCrownPenaltyFxUntil || 0) : 0);
if (botOut) ctx.restore();
} else {
if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return;
const axMe = safeX(me.x);
const ayMe = safeY(me.y);
if (playQuizAvatarHideWhileOverlappingAnswerZone(me, myId, axMe, ayMe)) return;
if (isJumpSurvive() && jumpSurviveEliminated) ctx.save();
if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4;
const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction;
drawAvatar(axMe, ayMe, true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me), isGauntletCrownHeistMapPlay() ? (me.gauntletCrownPenaltyFxUntil || 0) : 0);
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 (!(isGauntletCrownHeistMapPlay() && gauntletCrownRunwayBgDrawActivePlay())) {
if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) {
const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000));
const mm = Math.floor(rem / 60);
const ss = rem % 60;
const clock = `${mm}:${String(ss).padStart(2, '0')}`;
ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60);
} else if (gauntletRuntimeTimeLimitSec > 0) {
ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60);
} else {
ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60);
}
}
}
if (isStack() && stackMini) {
if (playBotsEnabled()) {
if (!playHideTopChrome()) {
ctx.fillStyle = 'rgba(125, 220, 255, 0.9)';
ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22);
}
/* Tower mnn93hpi: แผงขวา (ชีวิตทีม + อวตาร์) วาดบน canvas — ต้องวาดแม้ซ่อนแถบห้องบน */
if (!playHideTopChrome() || (isStackTowerMissionUiMapPlay() && useCyberPlayHud())) {
drawStackPreviewCyberHud(ctx, canvas.width, canvas.height);
}
} else {
ctx.fillStyle = '#7dcfff';
ctx.font = 'bold 15px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24);
ctx.font = '12px sans-serif';
ctx.fillStyle = '#c0caf5';
ctx.fillText('Space / คลิก = ปล่อย', 10, 44);
}
}
if (isQuiz() || isQuizCarry() || isQuizBattle()) {
syncPlayQuizMapPanel();
syncQuizCarryEmbedQuestionStrip();
}
/* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */
syncQuizCarryTimeupDeskLayerPosition();
if (playBotsEnabled() && mapData && !useCyberPlayHud()) {
const human = countPlayHumans();
const bots = [...others.keys()].filter(isPreviewBotId).length;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = 'rgba(26,27,38,0.78)';
const hudW = isStack() ? Math.min(canvas.width - 12, 520) : Math.min(canvas.width - 12, 320);
ctx.fillRect(6, canvas.height - 38, hudW, 30);
ctx.fillStyle = '#a9b1d6';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
const botTarget = playBotTargetHeadcount();
let line = detectiveCaseFillBots
? ('คดีสืบสวน: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)')
: ('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + botTarget + ' คน)');
if (isStack()) {
line += ' · ตึกร่วม + HUD ซ้าย/ขวา';
}
ctx.fillText(line, 12, canvas.height - 18);
ctx.restore();
}
if (isSpaceShooter()) {
drawSpaceShooterCombatLayer(ctx, worldToScreen, zDraw, timeMs);
}
if (isBalloonBoss()) {
drawBalloonBossCombatLayer(ctx, worldToScreen, zDraw, timeMs);
}
if (isQuizCarry()) {
drawQuizCarryEmbedCountdownHighlight(ctx, worldToScreen, zDraw, timeMs);
}
if (quizCarryEmbedMissionFlowActive()
&& (quizCarryEmbedCountdownEndAt > Date.now() || quizCarryEmbedPreOptionCountdownEndAt > Date.now())) {
syncQuizCarryEmbedCountdownLayout();
}
drawQuizTfScorePopupsLayer(ctx, worldToScreen, zDraw, timeMs);
syncPlayCyberHud();
syncQuizQuestionMissionJoystickPlay();
syncQuizCarryGrabButton();
syncGauntletCrownJumpButton();
syncStackTowerDropButtonPlay();
}
document.addEventListener('keydown', (e) => {
if (isMovementKey(e.code) && isChatFocused()) return;
if (previewMode && editorEmbedReturn && mapData && !isChatFocused() && !isQuizQuestionMissionHudActivePlay() && !isStackTowerEmbedZoomLockedPlay()) {
if (e.code === 'BracketLeft' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
e.preventDefault();
playEmbedUserZoomMul = Math.max(PLAY_EMBED_USER_ZOOM_MIN, playEmbedUserZoomMul / PLAY_EMBED_ZOOM_STEP_KEY);
draw();
return;
}
if (
e.code === 'BracketRight'
|| e.code === 'NumpadAdd'
|| (e.code === 'Equal' && e.shiftKey)
) {
e.preventDefault();
playEmbedUserZoomMul = Math.min(PLAY_EMBED_USER_ZOOM_MAX, playEmbedUserZoomMul * PLAY_EMBED_ZOOM_STEP_KEY);
draw();
return;
}
}
if (isSpaceShooter() && mapData && !isChatFocused() && isSpaceShooterMissionPregameBlockingPlay()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
return;
}
if (isQuiz() && mapData && !isChatFocused() && isQuizQuestionMissionPregameBlockingPlay()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
return;
}
if (isStack() && mapData && !isChatFocused() && isStackTowerMissionPregameBlockingPlay()) {
if (['Space', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
return;
}
if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
return;
}
if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
}
}
if (isStack() && mapData && !isChatFocused()) {
if (e.code === 'Space' || e.code === 'Enter' || e.code === 'NumpadEnter') {
e.preventDefault();
tryHumanStackDrop();
return;
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
return;
}
}
if (isGauntlet() && mapData && !isChatFocused()) {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
tryRequestGauntletJumpPlay();
return;
}
if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) {
e.preventDefault();
return;
}
}
if (isJumpSurvive() && mapData && !isChatFocused()) {
if (isJumpSurviveMissionPregameBlockingPlay()) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
return;
}
if (jumpSurviveEliminated) {
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault();
return;
}
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
if (!e.repeat) jumpSurviveJumpQueued = true;
return;
}
if (e.code === 'ArrowDown' || e.code === 'KeyS') {
e.preventDefault();
return;
}
}
if (isQuizBattle() && mapData && !isChatFocused()) {
if (e.code === 'KeyE') {
e.preventDefault();
if (!e.repeat && myId != null) tryOpenQuizBattleFromKey();
return;
}
}
if (isQuizCarry() && mapData && !isChatFocused()) {
if (e.code === 'KeyF') {
e.preventDefault();
if (!e.repeat && myId != null) tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true });
return;
}
}
keys[e.code] = true;
keys[e.key] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if (isFrogger() && mapData && !isChatFocused()) {
const now = Date.now();
if (now - lastFroggerKey < 280) return;
let dx = 0, dy = 0;
if (e.code === 'ArrowUp' || e.code === 'KeyW') { dy = -1; me.direction = 'up'; }
else if (e.code === 'ArrowDown' || e.code === 'KeyS') { dy = 1; me.direction = 'down'; }
else if (e.code === 'ArrowLeft' || e.code === 'KeyA') { dx = -1; me.direction = 'left'; }
else if (e.code === 'ArrowRight' || e.code === 'KeyD') { dx = 1; me.direction = 'right'; }
if (dx !== 0 || dy !== 0) {
const nx = Math.round(me.x) + dx, ny = Math.round(me.y) + dy;
if (nx >= 0 && nx < mapData.width && ny >= 0 && ny < mapData.height) {
const row = mapData.objects && mapData.objects[ny];
if (!row || row[nx] !== 1) {
me.x = nx; me.y = ny;
lastFroggerKey = now;
socket.emit('move', { x: me.x, y: me.y, direction: me.direction });
const lane = getLane(Math.floor(me.y));
if (lane && lane.type === 'goal') { froggerScore++; respawnFrogger(); }
else if (checkFroggerCollision()) respawnFrogger();
}
}
}
}
});
document.addEventListener('keyup', (e) => { keys[e.code] = false; keys[e.key] = false; });
let lastSend = 0;
function tick() {
if (!mapData) { requestAnimationFrame(tick); return; }
if (isStack()) {
if (isStackTowerEmbedZoomLockedPlay()) {
playEmbedUserZoomMul = PLAY_EMBED_STACK_TOWER_FIXED_ZOOM_MUL;
}
const nowStBg = performance.now();
const dtStBg = Math.min(0.06, (nowStBg - lastStackTowerScrollBgTickMs) / 1000);
lastStackTowerScrollBgTickMs = nowStBg;
tickStackTowerScrollBg(dtStBg);
if (isStackTowerMissionPregameBlockingPlay()) {
updateStackTowerSwingYForStripGapPlay();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
stackTickFrame();
updateStackTowerSwingYForStripGapPlay();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isJumpSurvive()) {
if (isJumpSurviveMissionPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
jumpSurviveTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isQuiz() && isQuizQuestionMissionUiMapPlay() && isQuizQuestionMissionPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isSpaceShooter()) {
if (isSpaceShooterMissionPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
spaceShooterTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isBalloonBoss()) {
balloonBossTickFrame();
draw();
requestAnimationFrame(tick);
return;
}
if (isFrogger()) {
if (checkFroggerCollision()) respawnFrogger();
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
if (isGauntlet()) {
{
const nowBg = performance.now();
const dtBg = Math.min(0.06, lastGauntletCrownRunwayBgTickMs ? (nowBg - lastGauntletCrownRunwayBgTickMs) / 1000 : 0.016);
lastGauntletCrownRunwayBgTickMs = nowBg;
tickGauntletCrownRunwayBgPlay(dtBg);
}
if (isGauntletCrownPregameBlockingPlay()) {
me.isWalking = false;
draw();
requestAnimationFrame(tick);
return;
}
me.isWalking = (isGauntletCrownHeistMapPlay() && gauntletCrownPregamePhase === 'live' && !me.gauntletEliminated && gauntletCrownRunwayAvatarRunAllowedPlay()) || meGauntletJumpTicks > 0;
const stepGauntletJumpVis = (vis, tgt) => {
const t = Number(tgt) || 0;
let v = Number(vis) || 0;
const d = t - v;
const k = d < 0 ? 0.68 : 0.4;
return v + d * k;
};
meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks);
const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty);
me.x = mp.nx;
me.y = mp.ny;
others.forEach((o) => {
const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty);
o.x = op.nx;
o.y = op.ny;
if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0;
o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0);
});
clampPlayEntityFootprintToMap(me, mapData);
others.forEach((o) => {
if (o) clampPlayEntityFootprintToMap(o, mapData);
});
let zGa = zoom;
if (previewMode && editorEmbedReturn && mapData) zGa *= playEmbedUserZoomMul;
const gCam = getGauntletCrownHeistGroupCameraCenterPxPlay(tileSize, canvas.width, canvas.height, zGa);
if (gCam) maybeEliminateGauntletPreviewBotsTailOffCameraPlay(gCam.px, gCam.py, zGa);
draw();
requestAnimationFrame(tick);
return;
}
/* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */
others.forEach((o, id) => {
if (playBotsEnabled() && isPreviewBotId(id)) return;
if (o.tx != null) o.x += (o.tx - o.x) * LERP;
if (o.ty != null) o.y += (o.ty - o.y) * LERP;
});
tickQuizCarryEmbedCountdown();
tickQuizCarryRoundTimers();
updateQuizCarryCarryPhaseHud();
stepPreviewBots();
if (isChatFocused()) {
me.isWalking = false;
enforceQuizBattleLaneOnMePlay();
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) {
let kx = 0;
let ky = 0;
if (keys['ArrowLeft'] || keys['KeyA']) kx -= 1;
if (keys['ArrowRight'] || keys['KeyD']) kx += 1;
if (keys['ArrowUp'] || keys['KeyW']) ky -= 1;
if (keys['ArrowDown'] || keys['KeyS']) ky += 1;
let jx = 0;
let jy = 0;
if (isQuizQuestionMissionHudActivePlay()) {
jx = quizQuestionMissionJoyVecX;
jy = quizQuestionMissionJoyVecY;
}
accX = kx + jx;
accY = ky + jy;
if (accX !== 0 || accY !== 0) {
if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up';
else if (accX !== 0) me.direction = accX > 0 ? 'right' : 'left';
}
}
const preWalkX = me.x, preWalkY = me.y;
if (accX !== 0 || accY !== 0) {
const len = Math.sqrt(accX * accX + accY * accY) || 1;
const step = Math.min(moveSpeedTilesThisFrameForWalk(), len);
const 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(playDesignMainMenuHref());
}
});
(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 wireGauntletCrownJumpButton() {
const btn = document.getElementById('gauntlet-crown-jump-btn');
if (!btn) return;
btn.addEventListener('click', (ev) => {
ev.preventDefault();
if (!mapData || !isGauntlet() || isChatFocused()) return;
tryRequestGauntletJumpPlay();
});
})();
(function wireStackTowerDropButton() {
const btn = document.getElementById('stack-tower-drop-btn');
if (!btn) return;
btn.addEventListener('click', (ev) => {
ev.preventDefault();
if (!mapData || !isStack() || isChatFocused()) return;
if (!btn.classList.contains('stack-tower-drop-btn--active')) return;
tryHumanStackDrop();
});
})();
(function wireQuizQuestionMissionJoystick() {
const base = document.getElementById('quiz-q-mission-joystick-base');
const bg = document.getElementById('quiz-q-mission-joystick-bg');
const knobImg = document.getElementById('quiz-q-mission-joystick-knob-img');
if (!base) return;
if (bg) {
bg.src = questionMissionAssetUrl('btn-joystick-1.png');
bg.onerror = function () {
this.onerror = null;
this.removeAttribute('src');
};
}
if (knobImg) {
knobImg.src = questionMissionAssetUrl('btn-joystick-2.png');
knobImg.onerror = function () {
this.onerror = null;
this.removeAttribute('src');
};
}
function endJoy(e) {
if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
try {
base.releasePointerCapture(e.pointerId);
} catch (_err) { /* ignore */ }
quizQuestionMissionJoyPointerId = null;
quizQuestionMissionJoystickResetVisual();
}
base.addEventListener('pointerdown', (e) => {
if (!isQuizQuestionMissionHudActivePlay() || isChatFocused()) return;
if (e.button != null && e.button !== 0) return;
e.preventDefault();
try {
base.setPointerCapture(e.pointerId);
} catch (_err) { /* ignore */ }
quizQuestionMissionJoyPointerId = e.pointerId;
quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
});
base.addEventListener('pointermove', (e) => {
if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
e.preventDefault();
quizQuestionMissionJoystickUpdateFromClientXY(e.clientX, e.clientY);
});
base.addEventListener('pointerup', endJoy);
base.addEventListener('pointercancel', endJoy);
base.addEventListener('lostpointercapture', (e) => {
if (quizQuestionMissionJoyPointerId == null || e.pointerId !== quizQuestionMissionJoyPointerId) return;
quizQuestionMissionJoyPointerId = null;
quizQuestionMissionJoystickResetVisual();
});
})();
(function wireGauntletCrownHowtoPrimary() {
const btn = document.getElementById('btn-gch-ready');
if (!btn) return;
btn.addEventListener('click', () => {
if (isJumpSurviveMissionUiMapPlay()) {
if (jumpSurviveMissionPhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
if (totPlayers === 1) {
if (!(humans.length === 1 || isMePlayHost())) return;
beginJumpSurviveMissionCountdownThenRun();
return;
}
if (!isMePlayHost()) {
if (myId == null) return;
const sid = String(myId);
const next = !gauntletCrownLobbyReadyMap[sid];
gauntletCrownLobbyReadyMap[sid] = next;
updateJumpSurviveMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
return;
}
if (myId == null) return;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReady) {
socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
return;
}
const sidH = String(myId);
const nextH = !gauntletCrownLobbyReadyMap[sidH];
gauntletCrownLobbyReadyMap[sidH] = nextH;
updateJumpSurviveMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
return;
}
if (isSpaceShooterMissionUiMapPlay()) {
if (spaceShooterMissionPhase !== 'howto') return;
const humansSs = quizCarryPregameHumanIds();
const totPlayersSs = Math.max(1, quizCarryPregameTotalPlayers());
if (totPlayersSs === 1) {
if (!(humansSs.length === 1 || isMePlayHost())) return;
beginSpaceShooterMissionCountdownThenRun();
return;
}
if (!isMePlayHost()) {
if (myId == null) return;
const sidSs = String(myId);
const nextSs = !gauntletCrownLobbyReadyMap[sidSs];
gauntletCrownLobbyReadyMap[sidSs] = nextSs;
updateSpaceShooterMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSs });
return;
}
if (myId == null) return;
const humansReadySs = humansSs.length > 0 && humansSs.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReadySs) {
socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
return;
}
const sidHostSs = String(myId);
const nextHostSs = !gauntletCrownLobbyReadyMap[sidHostSs];
gauntletCrownLobbyReadyMap[sidHostSs] = nextHostSs;
updateSpaceShooterMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSs });
return;
}
if (isQuizQuestionMissionUiMapPlay()) {
if (quizQuestionMissionPhase !== 'howto') return;
const humans = quizCarryPregameHumanIds();
const totPlayers = Math.max(1, quizCarryPregameTotalPlayers());
if (totPlayers === 1) {
beginQuizQuestionMissionCountdownThenRun();
return;
}
if (!isMePlayHost()) {
if (myId == null) return;
const sid = String(myId);
const next = !gauntletCrownLobbyReadyMap[sid];
gauntletCrownLobbyReadyMap[sid] = next;
updateQuizQuestionMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: next });
return;
}
if (myId == null) return;
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReady) {
socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
return;
}
const sidH = String(myId);
const nextH = !gauntletCrownLobbyReadyMap[sidH];
gauntletCrownLobbyReadyMap[sidH] = nextH;
updateQuizQuestionMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextH });
return;
}
if (isStackTowerMissionUiMapPlay()) {
if (stackTowerMissionPhase !== 'howto') return;
const humansSt = quizCarryPregameHumanIds();
const totPlayersSt = Math.max(1, quizCarryPregameTotalPlayers());
if (totPlayersSt === 1) {
if (!(humansSt.length === 1 || isMePlayHost())) return;
beginStackTowerMissionCountdownThenRun();
return;
}
if (!isMePlayHost()) {
if (myId == null) return;
const sidSt = String(myId);
const nextSt = !gauntletCrownLobbyReadyMap[sidSt];
gauntletCrownLobbyReadyMap[sidSt] = nextSt;
updateStackTowerMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextSt });
return;
}
if (myId == null) return;
const humansReadySt = humansSt.length > 0 && humansSt.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReadySt) {
socket.emit('gauntlet-crown-lobby-start', {}, (r) => { if (!r || !r.ok) { /* ignore */ } });
return;
}
const sidHostSt = String(myId);
const nextHostSt = !gauntletCrownLobbyReadyMap[sidHostSt];
gauntletCrownLobbyReadyMap[sidHostSt] = nextHostSt;
updateStackTowerMissionHowtoHud();
if (socket && socket.connected) socket.emit('gauntlet-crown-lobby-ready', { ready: nextHostSt });
return;
}
if (!usesCrownLobbyShellPlay()) return;
if (gauntletCrownPregamePhase !== 'howto') return;
if (myId == null || !isMePlayHost()) return;
const humans = quizCarryPregameHumanIds();
const humansReady = humans.length > 0 && humans.every((id) => !!gauntletCrownLobbyReadyMap[id]);
if (humansReady) {
if (humans.length === 1 && isMePlayHost()) {
beginGauntletCrownCountdownThenRun();
return;
}
socket.emit('gauntlet-crown-lobby-start', {}, (r) => {
if (!r || !r.ok) { /* ignore */ }
});
return;
}
const sid = String(myId);
const next = !gauntletCrownLobbyReadyMap[sid];
gauntletCrownLobbyReadyMap[sid] = next;
updateGauntletCrownHowtoHud();
socket.emit('gauntlet-crown-lobby-ready', { ready: next });
});
})();
(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();
});
/** แคนวาส flex ขยายทีหลัง (จอใหญ่ / editor iframe) — ResizeObserver จับได้ดีกว่า resize อย่างเดียว */
(function setupPlayCanvasStackResizeObserver() {
const stack = document.getElementById('play-canvas-stack');
if (!stack || typeof ResizeObserver === 'undefined') return;
let roRaf = null;
const run = () => {
roRaf = null;
if (!mapData || !canvas) return;
resizeCanvas();
draw();
};
const ro = new ResizeObserver(() => {
if (roRaf) cancelAnimationFrame(roRaf);
roRaf = requestAnimationFrame(run);
});
ro.observe(stack);
const wrap = stack.closest('.game-wrap');
if (wrap) ro.observe(wrap);
const stageEl = document.getElementById('play-canvas-stage');
if (stageEl) ro.observe(stageEl);
})();
})();