From 265a82601b9f6c4763097de0922a584be824ff84 Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Thu, 21 May 2026 16:59:49 +0000 Subject: [PATCH] vote and bot on lobby --- www/html/Game/public/js/play.js | 3 +- .../public/js/play.js.bak-20260521-230000 | 18703 ++++++++++++++++ www/html/Game/public/js/room-lobby.js | 499 +- .../js/room-lobby.js.bak-20260521-221647 | 4326 ++++ .../js/room-lobby.js.bak-20260521-224001 | 4361 ++++ .../js/room-lobby.js.bak-20260521-224750 | 4374 ++++ .../js/room-lobby.js.bak-20260521-230833 | 4541 ++++ .../js/room-lobby.js.bak-20260521-232213 | 4583 ++++ .../js/room-lobby.js.bak-20260521-234519 | 4608 ++++ www/html/Game/server.js | 213 + www/html/Game/server.js.bak-20260521-221647 | 4950 ++++ www/html/Game/server.js.bak-20260521-224750 | 4967 ++++ www/html/Game/server.js.bak-20260521-234519 | 5064 +++++ 13 files changed, 61183 insertions(+), 9 deletions(-) create mode 100644 www/html/Game/public/js/play.js.bak-20260521-230000 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-221647 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-224001 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-224750 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-230833 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-232213 create mode 100644 www/html/Game/public/js/room-lobby.js.bak-20260521-234519 create mode 100644 www/html/Game/server.js.bak-20260521-221647 create mode 100644 www/html/Game/server.js.bak-20260521-224750 create mode 100644 www/html/Game/server.js.bak-20260521-234519 diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 898d7d3..890bf52 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -1782,9 +1782,10 @@ 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'); - add(base + '_layer_' + layerName + '.png'); return out; } diff --git a/www/html/Game/public/js/play.js.bak-20260521-230000 b/www/html/Game/public/js/play.js.bak-20260521-230000 new file mode 100644 index 0000000..898d7d3 --- /dev/null +++ b/www/html/Game/public/js/play.js.bak-20260521-230000 @@ -0,0 +1,18703 @@ +(function () { + const BASE = '/Game'; + /** รูปเลข 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'); + const nick = params.get('nick') || 'ผู้เล่น'; + 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(BASE + '/lobby.html'); return; } + + const socket = io({ path: BASE + '/socket.io' }); + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + let mapData = null, tileSize = 32, myId = null; + let stackMini = null; + let stackFall = null; + /** 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', '#ef62b9']; + const PLAY_TINT_BODY = ['#fb4941', '#feaa11', '#fefe6d', '#adfd85', '#45fbfd', '#799afe', '#f87dff', '#fec1fe']; + const playLayerMode = {}; + /** คิว probe ครบ up/down/left/right แล้ว — กันทิศที่ไม่มีไฟล์ layer ไปตก fallback แถบทั้งทั้งที่ทิศอื่นมี */ + const playLayerAllDirsQueued = {}; + /** จาก GET /api/characters — hasLayerFiles สแกนจากชื่อไฟล์จริง (แม่นกว่า probe จาก อย่างเดียว) */ + 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], + }; + } + + function hexToRgb01(hex) { + const h = (hex || '').replace('#', '').trim(); + if (h.length !== 6) return [1, 1, 1]; + return [ + parseInt(h.slice(0, 2), 16) / 255, + parseInt(h.slice(2, 4), 16) / 255, + parseInt(h.slice(4, 6), 16) / 255, + ]; + } + + function tintPlayLayerImageData(imageData, tintHex) { + const rgb = hexToRgb01(tintHex); + const tr = rgb[0], tg = rgb[1], tb = rgb[2]; + const d = imageData.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 12) continue; + const r = d[i], g = d[i + 1], b = d[i + 2]; + const L = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + d[i] = Math.min(255, Math.round(tr * 255 * L)); + d[i + 1] = Math.min(255, Math.round(tg * 255 * L)); + d[i + 2] = Math.min(255, Math.round(tb * 255 * L)); + } + } + + function drawPlayTintedLayer(ctx, w, h, img, tintHex) { + if (!tintHex) { + ctx.drawImage(img, 0, 0, w, h); + return; + } + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const x = c.getContext('2d'); + x.drawImage(img, 0, 0, w, h); + try { + const idata = x.getImageData(0, 0, w, h); + tintPlayLayerImageData(idata, tintHex); + x.putImageData(idata, 0, 0); + } catch (e) { + x.clearRect(0, 0, w, h); + x.drawImage(img, 0, 0, w, h); + } + ctx.drawImage(c, 0, 0, w, h); + } + + function ensurePlayLayerImage(url) { + let img = playLayerImageCache.get(url); + if (!img) { + img = new Image(); + img.src = url; + playLayerImageCache.set(url, img); + } + return img; + } + + /** ลำดับลอง URL: เฟรมปัจจุบันแบบ multi → เฟรม 0 → แบบ single (ไม่มี _0_) เพื่อกันชื่อไฟล์ไม่ตรงกับที่เซิร์ฟเวอร์เขียน */ + function layerUrlCandidates(id, dir, layerName, frameIndex) { + const enc = encodeURIComponent(id); + const base = BASE + '/img/characters/' + enc + '_' + dir; + const out = []; + function add(u) { + if (out.indexOf(u) === -1) out.push(u); + } + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + add(base + '_layer_' + layerName + '.png'); + return out; + } + + /** เลเยอร์ idle: id__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); + } + add(base + '_' + frameIndex + '_layer_' + layerName + '.png'); + if (frameIndex !== 0) add(base + '_0_layer_' + layerName + '.png'); + add(base + '_layer_' + layerName + '.png'); + return out; + } + + function shadowUrlCandidates(id, dir, frameIndex) { + const c = layerUrlCandidates(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + function shadowUrlCandidatesIdle(id, dir, frameIndex) { + const c = layerUrlCandidatesIdle(id, dir, 'shadow', frameIndex); + const def = BASE + '/img/default-shadow-' + dir + '.png'; + if (c.indexOf(def) === -1) c.push(def); + return c; + } + + /** ลองทีละ URL — อย่า set src พร้อมกันหลายรูป (เดิมทำให้ legacy `id_dir_layer_x` โดน 404 ทุกเลเยอร์ทุกทิศทั้งที่มีแค่ `id_dir_0_layer_x`) */ + function resolvePlayLayerImage(urls) { + for (let i = 0; i < urls.length; i++) { + const img = ensurePlayLayerImage(urls[i]); + if (!img.complete) return { status: 'pending' }; + if (img.naturalWidth > 0) return { status: 'ok', img }; + } + return { status: 'missing' }; + } + + function ensurePlayLayerProbesAllDirections(characterId) { + if (!characterId || playLayerAllDirsQueued[characterId]) return; + playLayerAllDirsQueued[characterId] = true; + ['up', 'down', 'left', 'right'].forEach((d) => ensurePlayLayerProbe(characterId, d)); + } + + function playCharAnyDirectionLayered(characterId) { + if (!characterId) return false; + return ['up', 'down', 'left', 'right'].some((d) => playLayerMode[characterId + '|' + d] === 'layered'); + } + + /** รอเฉพาะตอนยังไม่เจอ layered เลย — ถ้ามีทิศหนึ่ง layered แล้ว ไม่ต้องรอทิศอื่น */ + function playCharLayerDiscoveryPending(characterId) { + if (!characterId) return true; + const api = playCharLayerFromApi(characterId); + if (api === true || api === false) return false; + if (playCharAnyDirectionLayered(characterId)) return false; + return ['up', 'down', 'left', 'right'].some((d) => { + const m = playLayerMode[characterId + '|' + d]; + return m === undefined || m === 'pending'; + }); + } + + function ensurePlayLayerProbe(characterId, dir) { + const key = characterId + '|' + dir; + if (playLayerMode[key] !== undefined && playLayerMode[key] !== 'pending') return; + if (playLayerMode[key] === 'pending') return; + playLayerMode[key] = 'pending'; + const cands = layerUrlCandidates(characterId, dir, 'bodyColor', 0); + let ci = 0; + function tryNextProbe() { + if (playLayerMode[key] !== 'pending') return; + if (ci >= cands.length) { + playLayerMode[key] = 'none'; + return; + } + const url = cands[ci++]; + const img = ensurePlayLayerImage(url); + const fin = () => { + if (playLayerMode[key] !== 'pending') return; + if (img.naturalWidth > 0) { + playLayerMode[key] = 'layered'; + return; + } + tryNextProbe(); + }; + img.onload = fin; + img.onerror = fin; + if (img.complete) fin(); + } + tryNextProbe(); + } + + function getCharacterAnimFrameIndex(id, dir, now, isWalking) { + if (!id) return 0; + const key = id + '_' + dir; + const anim = characterAnimations[key]; + if (!anim) return 0; + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + return fi >= 0 ? fi : 0; + } + + function tryComposePlayLayersFromFiles(rawImg, id, dir, frameIndex, tint, opts) { + const useIdle = opts && opts.idle; + if (!rawImg || !rawImg.naturalWidth || !rawImg.naturalHeight) return null; + const w = rawImg.naturalWidth, h = rawImg.naturalHeight; + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const cctx = c.getContext('2d'); + let anyTintColorLayer = false; + let skippedShadowWhilePending = false; + const diskLayers = playCharDiskLayersSet(id); + /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ + const idleLayerStripes = !!useIdle; + const resDir = dir; + const resFi = frameIndex; + for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { + const layerName = PLAY_LAYER_ORDER[li]; + if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; + /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ + if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { + continue; + } + let urls; + if (layerName === 'shadow') { + urls = idleLayerStripes + ? shadowUrlCandidatesIdle(id, dir, frameIndex) + : shadowUrlCandidates(id, resDir, resFi); + } else { + urls = idleLayerStripes + ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) + : layerUrlCandidates(id, resDir, layerName, resFi); + } + const r = resolvePlayLayerImage(urls); + /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ + if (r.status === 'pending' && layerName === 'shadow') { + skippedShadowWhilePending = true; + continue; + } + if (r.status === 'pending') return null; + if (r.status === 'missing') continue; + const tintHex = PLAY_LAYER_TINT_KEY[layerName] ? tint[PLAY_LAYER_TINT_KEY[layerName]] : null; + if (PLAY_LAYER_TINT_KEY[layerName]) anyTintColorLayer = true; + drawPlayTintedLayer(cctx, w, h, r.img, tintHex); + } + /* ถ้าโหลดได้แต่ไม่มีเลเยอร์สีเลย จะเหลือแต่ stroke → ตัวขาว — ใช้ PNG รวมแทน */ + if (!anyTintColorLayer) return null; + return { canvas: c, skipCache: skippedShadowWhilePending }; + } + + function getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, tint) { + if (!tint || !characterId || !rawImg) return rawImg; + ensurePlayCharLayerListFetch(); + ensurePlayLayerProbesAllDirections(characterId); + const dirn = dir || 'down'; + const frameIndexWalk = getCharacterAnimFrameIndex(characterId, dirn, timeMs, isWalking); + const idlePhase = idleAnimPhaseIndex(typeof timeMs === 'number' ? timeMs : Date.now()); + + if (playCharLayerDiscoveryPending(characterId)) return rawImg; + + const apiFlag = playCharLayerFromApi(characterId); + /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ + const charHasLayerFiles = apiFlag === true + || (apiFlag == null && playCharAnyDirectionLayered(characterId)); + + /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ + if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { + return rawImg; + } + const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); + + if (charHasLayerFiles) { + if (!isWalking) { + const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; + const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); + const hitI = playLayerCompositeCache.get(cacheKeyIdle); + if (hitI) return hitI; + const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); + if (packI && packI.canvas) { + if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); + return packI.canvas; + } + } + const hit = playLayerCompositeCache.get(cacheKeyWalk); + if (hit) return hit; + const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, frameIndexWalk, tint, { idle: false }); + if (pack && pack.canvas) { + if (!pack.skipCache) playLayerCompositeCache.set(cacheKeyWalk, pack.canvas); + return pack.canvas; + } + return rawImg; + } + + /* + * ไม่มีไฟล์เลเยอร์ตาม API/probe — แสดง PNG รวม (ไม่ย้อมทั้งตัว: ไม่เหมือนระบบ gen สีแบบเลเยอร์เดิม) + */ + return rawImg; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const key = id + '_' + (direction || 'down'); + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + (direction || 'down') + '.png'; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const t = typeof now === 'number' ? now : Date.now(); + if (!isWalking) { + const idleAnim = ensureCharacterIdleAnim(id, dir); + let idlePhase = idleAnimPhaseIndex(t); + if (isUploadedCharAssetId(id)) idlePhase = 0; + const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); + if (idleFi >= 0) return idleAnim.frames[idleFi]; + const idfb = idleAnim.fallback; + if (idfb && idfb.complete && idfb.naturalWidth) return idfb; + } + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png'; + anim.frames.push(img); + } + anim.fallback = getCharacterImg(id, dir); + } + const phase = walkAnimPhaseIndex(now, isWalking); + const fi = pickLoadedWalkFrameIndex(anim, phase); + if (fi >= 0) return anim.frames[fi]; + const fb = anim.fallback; + if (fb && fb.complete && fb.naturalWidth) return fb; + return null; + } + + function getAvatarImg(characterId, direction, now, isWalking) { + const img = characterId ? getCharacterFrame(characterId, direction, now, isWalking) : null; + if (img) return img; + return defaultAvatarImg; + } + function getStoredCharacterId() { + try { + 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 (!previewMode || !editorEmbedReturn || !mapData || !isQuizCarry()) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const ov = document.getElementById('quiz-carry-embed-countdown'); + const inner = document.getElementById('quiz-carry-embed-countdown-inner'); + const canvasEl = document.getElementById('game-canvas'); + if (!ov || !inner || !canvasEl) return; + if (ov.classList.contains('is-hidden')) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + const anchor = carryEmbedCountdownAnchorResolved(); + if (anchor === 'screen') { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + let bounds = null; + if (anchor === 'grid') { + bounds = getTileBoundsForOnesGrid(mapData.carryEmbedCountdownArea); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } else { + bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + resetQuizCarryEmbedCountdownLayoutDom(); + return; + } + } + const 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); + } + + 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; + 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: playTintFromPeerId(id), + botTier, + gauntletJumpTicks: 0, + gauntletJumpVis: 0, + gauntletScore: crownStart ? 100 : 0, + gauntletEliminated: false, + quizCannotTrue: false, + quizCannotFalse: false, + quizCarryHeld: null, + botPath: [], + botAnswerWander: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), + jumpSurviveEliminated: false, + spaceShooterScore: 0, + balloonBossScore: 0, + 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(); + } + + 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; + 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; + } + 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 = rows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !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'; } + /** 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 (!(previewMode && editorEmbedReturn && isQuizCarry())) return false; + const now = Date.now(); + return (quizCarryEmbedCountdownEndAt > now) || (quizCarryEmbedPreOptionCountdownEndAt > now); + } + + function isQuizCarryEmbedLobbyBlockingMovement() { + return !!(previewMode && editorEmbedReturn && isQuizCarry() && quizCarryPregameActive); + } + + function quizCarryPregameHumanIds() { + const ids = []; + if (myId != null && !isPreviewBotId(myId)) ids.push(String(myId)); + others.forEach((_, id) => { + if (!isPreviewBotId(id)) ids.push(String(id)); + }); + return ids; + } + + function quizCarryPregameBotCount() { + let n = 0; + others.forEach((_, id) => { + if (isPreviewBotId(id)) n++; + }); + return n; + } + + function quizCarryPregameReadyNumerator() { + let n = quizCarryPregameBotCount(); + quizCarryPregameHumanIds().forEach((id) => { + if (quizCarryLobbyReadyMap[id]) n++; + }); + return n; + } + + function quizCarryPregameTotalPlayers() { + return quizCarryPregameHumanIds().length + quizCarryPregameBotCount(); + } + + function isMePlayHost() { + if (myId == null) return false; + if (playHostId == null) return true; + return String(playHostId) === String(myId); + } + + function hideQuizCarryPregameOverlay() { + const ov = document.getElementById('quiz-carry-pregame-overlay'); + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + } + + function showQuizCarryPregameOverlay() { + const ov = document.getElementById('quiz-carry-pregame-overlay'); + if (ov) { + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + } + updateQuizCarryPregameHud(); + } + + function quizCarrySyncGuestReadyIfNeeded() { + if (!quizCarryPregameActive || myId == null || isMePlayHost()) return; + const sid = String(myId); + if (quizCarryLobbyReadyMap[sid]) return; + quizCarryLobbyReadyMap[sid] = true; + if (socket && socket.connected) socket.emit('quiz-carry-lobby-ready', { ready: true }); + } + + function updateQuizCarryPregameHud() { + if (!quizCarryPregameActive) return; + const st = document.getElementById('quiz-carry-pregame-status'); + const primary = document.getElementById('quiz-carry-pregame-primary'); + const primaryImg = document.getElementById('quiz-carry-pregame-primary-img'); + const num = quizCarryPregameReadyNumerator(); + const tot = Math.max(1, quizCarryPregameTotalPlayers()); + if (st) { + st.textContent = 'Ready Status : ' + num + '/' + tot; + } + const humans = quizCarryPregameHumanIds(); + const humansReady = humans.length > 0 && humans.every((id) => !!quizCarryLobbyReadyMap[id]); + const meReady = !!(myId && quizCarryLobbyReadyMap[String(myId)]); + if (primaryImg) { + primaryImg.src = humansReady ? '/Game/img/quiz-carry/btn-start.png' : '/Game/img/quiz-carry/btn-ready.png'; + primaryImg.alt = humansReady ? 'START' : 'READY'; + } + if (primary) { + primary.classList.toggle('is-start-phase', humansReady); + primary.classList.toggle('is-read-only', !isMePlayHost()); + primary.classList.toggle('is-pressed', isMePlayHost() && meReady && !humansReady); + primary.disabled = !isMePlayHost(); + primary.setAttribute('aria-pressed', humansReady ? 'false' : (meReady ? 'true' : 'false')); + primary.title = isMePlayHost() + ? (humansReady ? 'START' : (meReady ? 'ยกเลิก READY' : 'READY')) + : (humansReady ? 'START (โฮสต์เท่านั้น)' : 'READY (โฮสต์เท่านั้น)'); + } + } + + function beginQuizCarryEmbedPregame() { + if (!(previewMode && editorEmbedReturn && isQuizCarry())) return; + quizCarryPregameActive = true; + quizCarryLobbyReadyMap = {}; + quizCarryPregameHumanIds().forEach((id) => { quizCarryLobbyReadyMap[id] = false; }); + showQuizCarryPregameOverlay(); + if (socket && socket.connected) socket.emit('quiz-carry-lobby-sync-request'); + quizCarrySyncGuestReadyIfNeeded(); + updateQuizCarryPregameHud(); + } + + function endQuizCarryEmbedPregameAndStart() { + if (!quizCarryPregameActive) return; + quizCarryPregameActive = false; + hideQuizCarryPregameOverlay(); + quizCarryPickNextQuestion(); + const nowEmb = Date.now(); + if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > nowEmb) + && !(previewMode && editorEmbedReturn && quizCarryEmbedPreOptionCountdownEndAt > nowEmb)) { + quizCarryRestoreEmbedPhaseLabel(); + } + } + + function quizCarryOptionsPickableNow() { + if (!isQuizCarry() || !quizCarryCurrent) return true; + /* embed: ระหว่าง 3–2–1 รอบสอง (คำถามขึ้นแล้ว) — ยังไม่วาด/ไม่ให้หยิบป้ายตัวเลือก */ + if (previewMode && editorEmbedReturn && 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()) 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 (previewMode && editorEmbedReturn && 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 (!previewMode || !editorEmbedReturn || !isQuizCarry()) 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 (previewMode && editorEmbedReturn) { + 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()) { + finishDetectiveMinigameAndReturnLobby(); + return; + } + if (!previewMode) { + quizCarryPickNextQuestion(); + return; + } + quizCarryRoundsCompleted++; + const lim = quizCarrySessionLength; + if (lim > 0 && quizCarryRoundsCompleted >= lim) { + showQuizCarrySessionCompleteOverlay(); + return; + } + quizCarryPickNextQuestion(); + } + + function playQuizCarryAvatarUrlForMission(characterId) { + if (!characterId) return ''; + return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; + } + + /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */ + function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) { + if (!imgEl || !characterId) return; + let attempts = 0; + const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId); + const tick = () => { + attempts++; + const nowT = Date.now(); + const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down'); + if (!rawImg) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) { + if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts); + setTimeout(tick, 90); + return; + } + if (!rawImg.naturalWidth) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + const tint = (peerId != null && myId != null && String(peerId) === String(myId)) + ? (me.playTint || playTintFromPeerId(String(peerId))) + : playTintFromPeerId(peerId != null ? String(peerId) : String(characterId)); + const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint); + if (out && out.tagName === 'CANVAS' && out.width > 0) { + try { + imgEl.src = out.toDataURL('image/png'); + return; + } catch (e) { /* ต่อไปลอง walk หรือ URL */ } + } + if (out && out.src && out !== rawImg) { + imgEl.src = out.src; + return; + } + if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) { + setTimeout(tick, 120); + return; + } + imgEl.src = rawImg.src || fallbackUrl; + }; + tick(); + } + + /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 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'); + 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 (isDetectiveMinigamePlay()) { + if (snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs; + if (snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs; + s.carrySessionLength = 1; + } else { + 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 (previewMode && editorEmbedReturn) { + beginQuizCarryEmbedPregame(); + } else if (isDetectiveMinigamePlay()) { + quizCarryPickNextQuestion(); + } else { + quizCarryPickNextQuestion(); + } + const nowJoin = Date.now(); + if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > nowJoin) + && !(previewMode && editorEmbedReturn && 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); + } else { + a.r = spaceShooterAsteroidRadiusFromHpPlay(a.hp); + } + } + } + + 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) => { + 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 [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; + 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(); + if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38; + 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 = getPlayTintedAvatarSource( + getAvatarImg(ent.characterId, faceDir, timeMs, !!ent.isWalking), + ent.characterId, faceDir, timeMs, !!ent.isWalking, + ent.playTint || playTintFromPeerId(entry.id) + ); + const size = 26 * z; + if (charImg && ((charImg.tagName === 'CANVAS' && charImg.width > 0) || (charImg.complete && charImg.naturalWidth))) { + const iw = charImg.tagName === 'CANVAS' ? charImg.width : charImg.naturalWidth; + const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight; + const sc = Math.min(size / iw, size / ih, 1); + const dw = iw * sc, dh = ih * sc; + 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'; + 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 = playTintFromPeerId(String((myId != null && myId !== '') ? myId : (nick || 'local'))); + { + 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: playTintFromPeerId(p.id), + gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + gauntletEliminated: !!p.gauntletEliminated, + jumpSurviveEliminated: false, + spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + 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(); + socket.emit('join-space', { + spaceId, + nickname: nick, + 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(BASE + '/lobby.html'); + } + return; + } + myId = socket.id; + let botSlots = parseInt(res.botSlotCount, 10); + if ((!botSlots || botSlots < 1) && res.maxPlayers > 0 && res.maxPlayers < 6) { + botSlots = 6 - res.maxPlayers; + } + if (botSlots > 0) { + detectiveCaseFillBots = true; + detectiveBotSlotCount = Math.min(6, Math.max(0, botSlots)); + } + if (isDetectiveMinigamePlay()) { + try { document.documentElement.classList.add('play-detective-minigame'); } catch (eD) { /* ignore */ } + } + if (res.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; + 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: playTintFromPeerId(data.id), + gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + jumpSurviveEliminated: false, + spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + 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 (!(previewMode && editorEmbedReturn && isQuizCarry())) return; + endQuizCarryEmbedPregameAndStart(); + }); + socket.on('gauntlet-crown-lobby-sync', (d) => { + if (!d || typeof d.readyMap !== 'object') return; + gauntletCrownLobbyReadyMap = { ...d.readyMap }; + if (gauntletCrownPregamePhase === 'howto') { + gauntletCrownSyncGuestReadyIfNeeded(); + updateGauntletCrownHowtoHud(); + } 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; + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) me.y = y; + } + if (data.direction) me.direction = data.direction; + if (data.characterId != null) me.characterId = data.characterId; + playPath = []; + return; + } + const o = others.get(data.id); + if (o) { + if (mapData && mapData.gameType === 'quiz_battle' && quizBattlePathModeActive(mapData) && !isPreviewBotId(data.id)) { + const px = Number(data.x); + const py = Number(data.y); + if (Number.isFinite(px) && Number.isFinite(py)) { + const sn = snapPositionOntoQuizBattlePathIfNeeded(px, py); + o.tx = sn.x; + o.ty = sn.y; + o.x = sn.x; + o.y = sn.y; + } else { + o.tx = data.x; + o.ty = data.y; + } + } else { + o.tx = data.x; + o.ty = data.y; + } + o.direction = data.direction || o.direction; + if (data.characterId != null) o.characterId = data.characterId; + if (mapData && mapData.gameType === 'space_shooter' && data.spaceShooterScore != null) { + const s = Number(data.spaceShooterScore); + if (Number.isFinite(s)) o.spaceShooterScore = Math.max(0, s); + } + if (mapData && mapData.gameType === 'balloon_boss') { + if (data.balloonBossScore != null) { + const s = Number(data.balloonBossScore); + if (Number.isFinite(s)) o.balloonBossScore = Math.max(0, s); + } + if (data.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; + }); + } + 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 = 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 || playTintFromPeerId(String(myId || 'me')), + }; + } + const o = entry.botId ? others.get(entry.botId) : null; + return { + name: o ? String(o.nickname || 'BOT').replace(/\s+/g, ' ').toUpperCase().slice(0, 12) : '—', + score: o ? (o.stackBotScore || 0) : 0, + characterId: o ? o.characterId : null, + playTint: o ? (o.playTint || playTintFromPeerId(entry.botId)) : null, + }; + } + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + + const boardW = Math.min(248, Math.max(200, cw * 0.36)); + const rowH = 42; + const headH = 38; + const boardH = headH + 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 rawImg = getAvatarImg(cid, dir, nowT, walk); + const peerId = row.id; + const tint = row.isMe + ? (me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me'))) + : (() => { + const ox = others.get(peerId); + return ox ? (ox.playTint || playTintFromPeerId(String(peerId))) : playTintFromPeerId(String(peerId)); + })(); + const cacheKey = [row.isMe ? 'me' : String(peerId), cid, tint.head, tint.hair, tint.body].join('|'); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + 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 (previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now()) { + const rem = Math.max(0, Math.ceil((quizCarryEmbedCountdownEndAt - Date.now()) / 1000)); + timeVal.textContent = String(rem); + } else if (previewMode && editorEmbedReturn && 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 || playTintFromPeerId(String(myId != null ? myId : 'me')); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { + try { + portrait.src = comp.toDataURL('image/png'); + } catch (e) { + if (rawImg && rawImg.src) portrait.src = rawImg.src; + } + } else if (comp && comp.src) { + portrait.src = comp.src; + } else if (rawImg && rawImg.src) { + portrait.src = rawImg.src; + } + } + if (statusEl) { + if (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 r = Math.max(14, (tileSize * zDraw * cellSpan) / 2 - 2); + const size = r * 2.2; + const dir = direction || 'down'; + const rawImg = getAvatarImg(characterId, dir, timeMs, isWalking); + const charImg = playTint + ? getPlayTintedAvatarSource(rawImg, characterId, dir, timeMs, isWalking, playTint) + : rawImg; + /* รองรับทั้ง และ canvas จากย้อมสี (canvas ไม่มี naturalWidth → เดิมตกวงกลม) */ + let iw = 0, ih = 0; + if (charImg && charImg.tagName === 'CANVAS' && charImg.width > 0 && charImg.height > 0) { + iw = charImg.width; + ih = charImg.height; + } else if (charImg && charImg.complete && charImg.naturalWidth) { + iw = charImg.naturalWidth; + ih = charImg.naturalHeight; + } + let halfSpriteW = r; + let spriteTopY = sy - 2 * r; + if (iw > 0 && ih > 0) { + const scale = Math.min(size / iw, size / ih, 1); + const drawW = iw * scale; + const drawH = ih * scale; + halfSpriteW = drawW / 2; + spriteTopY = sy - drawH; + ctx.drawImage(charImg, 0, 0, iw, ih, sx - drawW / 2, sy - drawH, drawW, drawH); + } else { + const cy = sy - r; + ctx.fillStyle = isMe ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(sx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + spriteTopY = cy - r; + } + ctx.fillStyle = '#c0caf5'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + const nameBase = name || ''; + const withScore = isGauntlet() && typeof gauntletScoreLabel === 'number' && gauntletScoreLabel >= 0 + ? (nameBase + ' · ' + gauntletScoreLabel) + : nameBase; + ctx.fillText(withScore, sx, sy + 10); + if (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 = playTintFromPeerId(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 || playTintFromPeerId(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; + if (botOut) ctx.save(); + if (botOut) ctx.globalAlpha = 0.4; + drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, peerName, o.characterId, faceDirOther, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0, quizCarrySignForEntity(o), isGauntletCrownHeistMapPlay() ? (o.gauntletCrownPenaltyFxUntil || 0) : 0); + if (botOut) ctx.restore(); + } else { + if (shouldGauntletCrownHeistSkipAvatarDrawPlay(me, true, 0, 0)) return; + if (isJumpSurvive() && jumpSurviveEliminated) ctx.save(); + if (isJumpSurvive() && jumpSurviveEliminated) ctx.globalAlpha = 0.4; + const faceDirMe = isGauntletFaceRightMapMno9kb07() ? 'right' : me.direction; + drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, faceDirMe, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0, quizCarrySignForEntity(me), 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()) { + ctx.fillStyle = 'rgba(125, 220, 255, 0.9)'; + ctx.font = '600 12px ui-sans-serif, system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Stack · shared tower · Space / click = release on your turn', 10, 22); + drawStackPreviewCyberHud(ctx, canvas.width, canvas.height); + } else { + ctx.fillStyle = '#7dcfff'; + ctx.font = 'bold 15px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Stack | คะแนน ' + stackMini.score + ' · ชีวิต ' + stackMini.lives + ' · combo x' + (stackMini.combo || 0), 10, 24); + ctx.font = '12px sans-serif'; + ctx.fillStyle = '#c0caf5'; + ctx.fillText('Space / คลิก = ปล่อย', 10, 44); + } + } + if (isQuiz() || isQuizCarry() || isQuizBattle()) { + syncPlayQuizMapPanel(); + syncQuizCarryEmbedQuestionStrip(); + } + /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */ + syncQuizCarryTimeupDeskLayerPosition(); + if (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 (previewMode && editorEmbedReturn && isQuizCarry() + && (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(BASE + '/lobby.html'); + } + }); + (function wireQuizBattleMcqModal() { + const ov = document.getElementById('quiz-battle-mcq-overlay'); + if (!ov) return; + ov.addEventListener('click', (e) => { + const t = e.target; + if (!t) return; + if (t.classList && t.classList.contains('quiz-battle-choice')) { + const idx = parseInt(t.getAttribute('data-idx'), 10); + if (Number.isFinite(idx) && idx >= 0 && idx <= 2) trySubmitQuizBattleChoice(idx); + return; + } + if (t.id === 'quiz-battle-mcq-close') { + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + return; + } + if (t.classList && t.classList.contains('quiz-battle-mcq-backdrop')) { + quizBattleModalCompId = null; + hideQuizBattleMcqModal(); + } + }); + })(); + (function wireQuizCarryGrabButton() { + const btn = document.getElementById('quiz-carry-grab-btn'); + if (!btn) return; + btn.addEventListener('click', (ev) => { + if (!isQuizCarry() || !mapData || isChatFocused()) return; + if (!btn.classList.contains('quiz-carry-grab-btn--active')) return; + ev.preventDefault(); + if (myId == null) return; + tryQuizCarryInteractionForPlayer(myId, me.x, me.y, { fromKey: true }); + }); + })(); + + (function 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); + })(); +})(); diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index a067896..ca953ab 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -140,6 +140,12 @@ let serverSuspectPhaseActive = false; /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ + let trialMode = null; + let myTrialVote = null; + let trialVoteCounts = [0, 0, 0]; let mapBackgroundImg = null; let hostIconImg = null; const peers = new Map(); @@ -2371,6 +2377,7 @@ ensureReadyControlEnabled(); if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); serverSuspectPhaseActive = !!res.suspectPhaseActive; if (res.suspectPhaseActive) { openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); @@ -3624,6 +3631,426 @@ suspectCardMinigames = cards; } + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */ + function injectTrialStyle() { + if (document.getElementById('trial-style')) return; + var st = document.createElement('style'); + st.id = 'trial-style'; + st.textContent = + '.suspect-pick--trial .suspect-pick-actions{display:none!important}' + + '.suspect-pick--trial #suspect-btn-accuse{display:none!important}' + /* #1 ปุ่มชี้ตัวต้องหายตอนหน้าโหวต */ + '#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' + + '#trial-vote-counter b{color:#fff}' + + '.suspect-pick--trial #trial-vote-counter{display:block}' + + '.suspect-pick--trial .suspect-pick-title-img{display:none!important}' + + '#trial-title{display:none;text-align:center;color:#7fe9ff;font:800 40px/1.1 Kanit,system-ui,sans-serif;text-shadow:0 0 18px rgba(34,211,238,.7);margin:0 0 2px}' + + '#trial-title small{display:block;color:#cfe9ff;font:600 18px/1.3 Kanit,system-ui,sans-serif;text-shadow:none;margin-top:6px}' + + '.suspect-pick--trial #trial-title{display:block}' + + '.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' + + // badge อยู่กลางล่างการ์ด (เลียนแบบปุ่ม "เลือกแล้ว" ใน mockup) + '.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);color:#06121f;font:700 15px/1 Kanit,system-ui,sans-serif;padding:7px 16px;border-radius:999px;display:none;z-index:6;white-space:nowrap;box-shadow:0 2px 10px rgba(0,0,0,.45)}' + + // ตอนโหวต: โชว์เฉพาะใบที่เราเลือก = "เลือกแล้ว" (เขียว) + '.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;background:rgba(34,197,94,.97)}' + + // ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ฟ้า) + '.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97)}' + + '.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' + + '.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' + + '#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' + + '#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}'; + document.head.appendChild(st); + } + + function ensureTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + if (!card.querySelector('.trial-vote-badge')) { + var b = document.createElement('span'); + b.className = 'trial-vote-badge'; + b.textContent = ''; + card.appendChild(b); + } + } + } + + function applyTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var b = card.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0); + } + } + + function ensureTrialBanner() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-result-banner'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-result-banner'; + el.style.display = 'none'; + ov.appendChild(el); + } + return el; + } + + function ensureTrialCounter() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-vote-counter'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-vote-counter'; + ov.appendChild(el); + } + return el; + } + + function setTrialCounter(voted, total) { + var el = ensureTrialCounter(); + if (el) el.innerHTML = 'ลงติกแล้ว : ' + (voted || 0) + '/' + (total || 0) + ''; + } + + function ensureTrialRevealBtn() { + var ov = document.getElementById('suspect-pick-overlay'); + var btn = document.getElementById('trial-reveal-btn'); + if (!btn && ov) { + btn = document.createElement('button'); + btn.id = 'trial-reveal-btn'; + btn.type = 'button'; + btn.textContent = 'เปิดเผยผล'; + btn.addEventListener('click', function () { + socket.emit('trial-reveal', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + ov.appendChild(btn); + } + if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none'; + } + + /** หัวข้อ "พิจารณาคดี" (แทนรูป title เลือกผู้ต้องสงสัยตอนอยู่โหมดโหวต) */ + function ensureTrialTitle() { + var row = document.getElementById('suspect-cards-row'); + var t = document.getElementById('trial-title'); + if (!t && row && row.parentNode) { + t = document.createElement('div'); + t.id = 'trial-title'; + t.innerHTML = 'พิจารณาคดีเลือกผู้ร้ายตัวจริง และส่งตัวไปดำเนินคดี เพื่อทำภารกิจให้สำเร็จ'; + row.parentNode.insertBefore(t, row); + } + return t; + } + + function setTrialSubtitle(txt) { + var t = document.getElementById('trial-title'); + if (!t) return; + var s = t.querySelector('small'); + if (s) s.textContent = txt; + } + + function castTrialVote(idx) { + if (trialMode !== 'voting') return; + myTrialVote = idx; + document.querySelectorAll('.suspect-card').forEach(function (c) { + var sel = parseInt(c.getAttribute('data-index'), 10) === idx; + c.classList.toggle('suspect-card--myvote', sel); + var b = c.querySelector('.trial-vote-badge'); + if (b && sel) b.textContent = '✓ เลือกแล้ว'; + }); + socket.emit('trial-vote', { index: idx }, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + } + + function openTrialVoteUI(data) { + trialMode = 'voting'; + myTrialVote = null; + trialVoteCounts = [0, 0, 0]; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) suspectProgress = [0, 1, 2].map(function (i) { return Math.min(3, Math.max(0, Math.floor(Number(data.suspectProgress[i])) || 0)); }); + injectTrialStyle(); + var ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + suspectPickOverlayOpen = true; + serverSuspectPhaseActive = false; + ov.classList.remove('is-hidden', 'trial-revealed'); + ov.classList.add('suspect-pick--trial'); + ov.setAttribute('aria-hidden', 'false'); + document.querySelectorAll('.suspect-card').forEach(function (c) { + c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected'); + }); + applySuspectProgressVisual(); + ensureTrialTitle(); + ensureTrialBadges(); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + setTrialCounter(0, (data && data.totalPlayers) || (peers ? peers.size : 0) || 1); + // กันปุ่มชี้ตัวค้าง: ใส่ is-hidden ตอนเข้าหน้าโหวต + var accuseBtn0 = document.getElementById('suspect-btn-accuse'); + if (accuseBtn0) accuseBtn0.classList.add('is-hidden'); + ensureTrialRevealBtn(); + var banner = ensureTrialBanner(); + if (banner) banner.style.display = 'none'; + var actions = document.getElementById('suspect-pick-actions'); + if (actions) actions.classList.remove('suspect-pick-actions--visible'); + var hint = document.getElementById('suspect-pick-hint'); + if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; } + updateSuspectFloatingOpenBtn(); + scheduleSuspectPickScale(); + } + + function showTrialResult(data) { + trialMode = 'revealed'; + var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0; + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; + var ov = document.getElementById('suspect-pick-overlay'); + if (ov) ov.classList.add('trial-revealed'); + // โชว์คะแนนโหวตทุกใบ + ไฮไลต์คนร้าย + document.querySelectorAll('.suspect-card').forEach(function (c) { + var ci = parseInt(c.getAttribute('data-index'), 10); + c.classList.remove('suspect-card--myvote'); + c.classList.toggle('suspect-card--culprit', ci === culprit); + var b = c.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[ci]) || 0); + }); + setTrialSubtitle('ผลการพิจารณาคดี'); + var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก'; + var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0); + var banner = ensureTrialBanner(); + if (banner) { + banner.innerHTML = 'คนร้ายตัวจริงคือ ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '
' + + 'ผู้ตอบถูก: ' + names + (iWon ? '
🎉 คุณตอบถูก! +100' : ''); + banner.style.display = 'block'; + } + var rb = document.getElementById('trial-reveal-btn'); + if (rb) rb.style.display = 'none'; + var hint = document.getElementById('suspect-pick-hint'); + if (hint) hint.textContent = 'ปิดหน้าต่างเพื่อกลับโถง'; + appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); + } + + /* ===== ห้องสรุปหลักฐาน — การไต่สวน (เลือกหลักฐาน 2 ใบ/ปากคำ ก่อนโหวต) ===== */ + var tmState = { round: 0, members: [], picks: [], submitted: false, mode: 'select' }; + var SUSPECT_NAMES_EN = ['SOMCHAI', 'ARIN', 'JIRAPHA']; + + function injectTestimonyStyle() { + if (document.getElementById('testimony-style')) return; + var st = document.createElement('style'); + st.id = 'testimony-style'; + st.textContent = + '#testimony-overlay{position:fixed;inset:0;z-index:9000;background:rgba(4,8,18,.92);display:flex;flex-direction:column;align-items:center;gap:14px;padding:24px 16px;overflow:auto;font-family:Kanit,system-ui,sans-serif;color:#e7ecff}' + + '#testimony-overlay.is-hidden{display:none}' + + '.tm-head{text-align:center}.tm-head h2{margin:0;color:#fff;font:800 30px/1.2 Kanit;text-shadow:0 0 16px rgba(34,211,238,.6)}.tm-head .tm-sub{margin:4px 0 0;color:#9fe7ff;font:600 16px/1.3 Kanit}' + + '.tm-members{display:flex;gap:12px;flex-wrap:wrap;justify-content:center;max-width:1100px}' + + '.tm-member{min-width:120px;border:2px solid #1e6f8c;border-radius:12px;padding:8px 10px;text-align:center;background:rgba(10,22,40,.7)}' + + '.tm-member .tm-mname{font:700 14px/1 Kanit;color:#bfe9ff;margin-bottom:6px;white-space:nowrap}' + + '.tm-member .tm-mstat{font:600 13px/1.2 Kanit;color:#8aa0c0}' + + '.tm-member.tm-ready{border-color:#22c55e;box-shadow:0 0 14px rgba(34,197,94,.5)}.tm-member.tm-ready .tm-mstat{color:#22c55e}' + + '.tm-member.tm-me{outline:2px solid #22d3ee;outline-offset:2px}' + + '.tm-crown{color:#ffd54a}' + + '.tm-count{color:#ffd54a;font:700 18px/1 Kanit}' + + '.tm-cards{display:flex;gap:14px;flex-wrap:wrap;justify-content:center;max-width:1100px}' + + '.tm-cards .lobby-evidence-card{cursor:pointer;transition:transform .12s,box-shadow .12s}' + + '.tm-cards .lobby-evidence-card.tm-sel{outline:3px solid #22d3ee;outline-offset:2px;transform:translateY(-4px);box-shadow:0 8px 26px rgba(34,211,238,.5)}' + + '.tm-actions{display:flex;gap:14px;align-items:center}' + + '.tm-btn{background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:13px 30px;font:700 19px/1 Kanit;cursor:pointer;box-shadow:0 6px 20px rgba(34,211,238,.45)}' + + '.tm-btn:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.5)}' + + '.tm-btn--ghost{background:transparent;color:#7fe9ff;border:2px solid #2a7e9c;box-shadow:none}' + + '.tm-reveal-grid{display:flex;gap:16px;flex-wrap:wrap;justify-content:center;max-width:1200px}' + + '.tm-rev-col{display:flex;flex-direction:column;align-items:center;gap:8px}' + + '.tm-rev-col .tm-rev-name{font:700 15px/1 Kanit;color:#bfe9ff}' + + '.tm-rev-col .lobby-evidence-card{transform:scale(.82);margin:-14px 0}' + + '.tm-status{color:#ffd54a;font:600 15px/1.3 Kanit;text-align:center}'; + document.head.appendChild(st); + } + + function tmEvidenceCardEl(c, linkName, cardIndex, selectable) { + var rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + var nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + var stars = ''; for (var st = 0; st < nStar; st++) stars += '★'; + var card = document.createElement('article'); + card.className = 'lobby-evidence-card lobby-evidence-card--' + rar; + if (selectable) card.setAttribute('data-card-index', String(cardIndex)); + card.innerHTML = + '
' + + '

' + + '' + + '

' + + '
' + + '
(' + LOBBY_EVIDENCE_RARITY[rar] + ')
' + stars + '
'; + card.querySelector('.lobby-evidence-card-title-th').textContent = c.titleTh || ''; + card.querySelector('.lobby-evidence-card-title-en').textContent = c.titleEn ? '(' + c.titleEn + ')' : ''; + card.querySelector('.lobby-evidence-card-body').textContent = c.body || ''; + card.querySelector('.lobby-evidence-avatar').textContent = (linkName || '?').charAt(0); + card.querySelector('.lobby-evidence-link-name').textContent = linkName || ''; + return card; + } + + function tmSuspectData(round) { + var payload = getLobbyEvidenceCasePayload(); + var suspects = payload.suspects || []; + var si = Math.max(0, Math.min(suspects.length - 1, round)); + return suspects[si] || { linkName: '?', cards: [] }; + } + + function ensureTestimonyOverlay() { + var ov = document.getElementById('testimony-overlay'); + if (ov) return ov; + injectTestimonyStyle(); + ov = document.createElement('div'); + ov.id = 'testimony-overlay'; + ov.className = 'is-hidden'; + ov.innerHTML = + '

' + + '
' + + '
' + + '
[เลือกพยานหลักฐาน 2 ชิ้น เพื่อประกอบสำนวนคดีนี้]เลือกหลักฐาน 0/2
' + + '
' + + '
' + + '
' + + '
' + + ''; + document.body.appendChild(ov); + ov.querySelector('#tm-cards').addEventListener('click', function (e) { + var card = e.target.closest('.lobby-evidence-card'); if (!card || tmState.submitted) return; + var idx = parseInt(card.getAttribute('data-card-index'), 10); + if (!(idx >= 0)) return; + var pos = tmState.picks.indexOf(idx); + if (pos >= 0) { tmState.picks.splice(pos, 1); card.classList.remove('tm-sel'); } + else { if (tmState.picks.length >= 2) return; tmState.picks.push(idx); card.classList.add('tm-sel'); } + tmUpdateCount(); + }); + ov.querySelector('#tm-submit').addEventListener('click', function () { + if (tmState.picks.length !== 2 || tmState.submitted) return; + socket.emit('testimony-submit', { picks: tmState.picks.slice(0, 2) }, function (res) { + if (res && res.ok) { tmState.submitted = true; tmSetStatus('ส่งหลักฐานแล้ว — รอผู้เล่นอื่น...'); document.getElementById('tm-submit').disabled = true; } + else appendLobbySystemChat('— ส่งหลักฐานไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + ov.querySelector('#tm-force-reveal').addEventListener('click', function () { + socket.emit('testimony-reveal', {}, function (res) { if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); }); + }); + ov.querySelector('#tm-ready').addEventListener('click', function () { + this.disabled = true; + socket.emit('testimony-ready-next', {}, function (res) { if (!(res && res.ok)) { document.getElementById('tm-ready').disabled = false; appendLobbySystemChat('— READY ไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); } }); + }); + return ov; + } + + function tmSetStatus(txt) { var el = document.getElementById('tm-status'); if (el) el.textContent = txt || ''; } + function tmUpdateCount() { + var el = document.getElementById('tm-count'); if (el) el.textContent = 'เลือกหลักฐาน ' + tmState.picks.length + '/2'; + var sub = document.getElementById('tm-submit'); if (sub) sub.disabled = (tmState.picks.length !== 2 || tmState.submitted); + } + + function tmRenderMembers(submittedIds, readyIds, revealMode) { + var root = document.getElementById('tm-members'); if (!root) return; + root.textContent = ''; + (tmState.members || []).forEach(function (m) { + var d = document.createElement('div'); + d.className = 'tm-member' + (m.id === socket.id ? ' tm-me' : ''); + var done = revealMode ? (readyIds && readyIds.indexOf(m.id) >= 0) : (submittedIds && submittedIds.indexOf(m.id) >= 0); + if (done) d.classList.add('tm-ready'); + d.innerHTML = '
' + (m.isHost ? ' ' : '') + (m.nickname || '') + '
' + (done ? (revealMode ? 'พร้อม' : 'พร้อม ✓') : (revealMode ? 'กำลังดู..' : 'กำลังเลือก..')) + '
'; + root.appendChild(d); + }); + } + + function openTestimony(data) { + if (typeof closeSuspectOverlay === 'function') closeSuspectOverlay(); + var ov = ensureTestimonyOverlay(); + tmState.round = (data && typeof data.round === 'number') ? data.round : 0; + tmState.members = (data && Array.isArray(data.members)) ? data.members : []; + tmState.picks = []; + tmState.submitted = false; + tmState.mode = 'select'; + var sd = tmSuspectData(tmState.round); + document.getElementById('tm-title').textContent = 'การไต่สวน : ปากคำที่ ' + (tmState.round + 1) + ' - ' + (sd.linkName || ('ผู้ต้องสงสัย ' + (tmState.round + 1))); + document.getElementById('tm-sub').textContent = 'ห้องสรุปหลักฐาน'; + document.getElementById('tm-select-wrap').style.display = 'flex'; + document.getElementById('tm-reveal-wrap').style.display = 'none'; + var fr = document.getElementById('tm-force-reveal'); + if (fr) fr.style.display = ((data && data.hostId) === socket.id) ? 'inline-block' : 'none'; + tmSetStatus(''); + // การ์ดของผู้ต้องสงสัยปัจจุบัน (ทุกคนเห็นครบ) + var cardsRoot = document.getElementById('tm-cards'); cardsRoot.textContent = ''; + (sd.cards || []).forEach(function (c, i) { cardsRoot.appendChild(tmEvidenceCardEl(c, sd.linkName, i, true)); }); + tmUpdateCount(); + tmRenderMembers([], [], false); + ov.classList.remove('is-hidden'); + } + + function testimonyStatusUpdate(data) { + if (!data) return; + tmRenderMembers(data.submitted || [], [], false); + } + + function testimonyReveal(data) { + tmState.mode = 'reveal'; + var ov = ensureTestimonyOverlay(); + ov.classList.remove('is-hidden'); + if (data && Array.isArray(data.members)) tmState.members = data.members; + document.getElementById('tm-select-wrap').style.display = 'none'; + document.getElementById('tm-reveal-wrap').style.display = 'flex'; + document.getElementById('tm-sub').textContent = 'วิเคราะห์พยานหลักฐานกับทีม ก่อนไปปากคำถัดไป'; + var rd = document.getElementById('tm-ready'); if (rd) rd.disabled = false; + var sd = tmSuspectData(tmState.round); + var grid = document.getElementById('tm-reveal-grid'); grid.textContent = ''; + var picks = (data && data.picks) || {}; + (tmState.members || []).forEach(function (m) { + var col = document.createElement('div'); col.className = 'tm-rev-col'; + var nm = document.createElement('div'); nm.className = 'tm-rev-name'; + nm.innerHTML = (m.isHost ? ' ' : '') + (m.nickname || ''); + col.appendChild(nm); + var sel = picks[m.id] || []; + if (!sel.length) { var none = document.createElement('div'); none.className = 'tm-status'; none.textContent = '(ไม่ได้ส่ง)'; col.appendChild(none); } + sel.forEach(function (ci) { var c = (sd.cards || [])[ci]; if (c) col.appendChild(tmEvidenceCardEl(c, sd.linkName, ci, false)); }); + grid.appendChild(col); + }); + tmRenderMembers([], [], true); + } + + function testimonyReadyUpdate(data) { + tmRenderMembers([], (data && data.ready) || [], true); + } + + function hideTestimonyOverlay() { + var ov = document.getElementById('testimony-overlay'); + if (ov) ov.classList.add('is-hidden'); + } + function applySuspectSelectionVisual(idx) { let i = Math.floor(Number(idx)); if (Number.isNaN(i) || i < 0 || i > 2) i = 0; @@ -3645,6 +4072,7 @@ const reopenSuspect = !!(data && data.suspectPhaseActive); const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); applyRoomLobbyBTransition({ mapId: mid, peersSnap: (data && data.peersSnap) ? data.peersSnap : [], @@ -3752,15 +4180,24 @@ btn.classList.toggle('is-hidden', !show); } + /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ + function allSuspectsInvestigated() { + return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); + } + function updateSuspectHostUi() { if (!suspectPickOverlayOpen) return; + if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง const isHost = hostId === socket.id; + const canAccuse = isHost && allSuspectsInvestigated(); const actions = document.getElementById('suspect-pick-actions'); const accuseBtn = document.getElementById('suspect-btn-accuse'); const hint = document.getElementById('suspect-pick-hint'); if (hint) { hint.textContent = isHost - ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + ? (allSuspectsInvestigated() + ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' + : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; hint.classList.toggle('is-hidden', false); } @@ -3769,8 +4206,9 @@ actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); } if (accuseBtn) { - accuseBtn.classList.toggle('is-hidden', !isHost); - accuseBtn.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว + accuseBtn.classList.toggle('is-hidden', !canAccuse); + accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); } document.querySelectorAll('.suspect-card').forEach((c) => { c.classList.toggle('suspect-card--host', isHost); @@ -3787,6 +4225,7 @@ ov.classList.remove('is-hidden'); ov.setAttribute('aria-hidden', 'false'); applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); updateSuspectHostUi(); updatePlayersHud(); updateSuspectFloatingOpenBtn(); @@ -3794,6 +4233,15 @@ function closeSuspectOverlay() { const ov = document.getElementById('suspect-pick-overlay'); + // รีเซ็ตสถานะพิจารณาคดี + if (trialMode) { + trialMode = null; myTrialVote = null; trialVoteCounts = [0, 0, 0]; + if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed'); + document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit'); }); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none'; + var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; + } const wrap = document.getElementById('suspect-pick-scale-wrap'); const inner = wrap && wrap.querySelector('.suspect-pick-inner'); if (inner) { @@ -3857,6 +4305,7 @@ serverSuspectPhaseActive = true; if (data && data.hostId != null) hostId = data.hostId; if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; openSuspectOverlay(idx); }); @@ -3866,6 +4315,29 @@ applySuspectSelectionVisual(idx); }); + // ===== ห้องสรุปหลักฐาน (ไต่สวน) ===== + socket.on('testimony-open', (data) => { openTestimony(data || {}); }); + socket.on('testimony-status', (data) => { testimonyStatusUpdate(data || {}); }); + socket.on('testimony-reveal', (data) => { testimonyReveal(data || {}); }); + socket.on('testimony-ready-update', (data) => { testimonyReadyUpdate(data || {}); }); + + // ===== ขั้นพิจารณาคดี ===== + socket.on('trial-open', (data) => { + hideTestimonyOverlay(); // ปิดห้องสรุปหลักฐานก่อนเข้าโหวต + openTrialVoteUI(data || {}); + }); + socket.on('trial-vote-update', (data) => { + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต) + if (trialMode === 'voting') setTrialCounter((data && data.voted) || 0, (data && data.totalPlayers) || 0); + const hint = document.getElementById('suspect-pick-hint'); + if (hint && trialMode === 'voting') { + hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; + } + }); + socket.on('trial-result', (data) => { + showTrialResult(data || {}); + }); + socket.on('suspect-investigation-start', (data) => { serverSuspectPhaseActive = false; closeSuspectOverlay(); @@ -3901,9 +4373,13 @@ document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { const card = ev.target.closest('.suspect-card'); - if (!card || !suspectPickOverlayOpen || hostId !== socket.id) return; + if (!card || !suspectPickOverlayOpen) return; const idx = parseInt(card.getAttribute('data-index'), 10); - if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + if (!(idx >= 0 && idx <= 2)) return; + if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้ + if (trialMode === 'revealed') return; + if (hostId !== socket.id) return; + selectSuspectCard(idx); }); document.getElementById('suspect-btn-start')?.addEventListener('click', () => { @@ -3912,9 +4388,14 @@ document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { if (!suspectPickOverlayOpen || hostId !== socket.id) return; - const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; - appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); - beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + if (!allSuspectsInvestigated()) { + appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); + return; + } + // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) + socket.emit('suspect-accuse-open', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); }); document.getElementById('suspect-pick-close')?.addEventListener('click', () => { @@ -3984,6 +4465,7 @@ renderPeers(); ensureReadyControlEnabled(); if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); tryTroublesomeLobbyGate(); @@ -4056,6 +4538,7 @@ closeSuspectOverlay(); closeLobbyPreplayWizard(); if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); if (data && data.quizMode && data.stayInRoomLobby) { const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; applyLobbyPeersSnapFromServer(data.peersSnap); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-221647 b/www/html/Game/public/js/room-lobby.js.bak-20260521-221647 new file mode 100644 index 0000000..a067896 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-221647 @@ -0,0 +1,4326 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-224001 b/www/html/Game/public/js/room-lobby.js.bak-20260521-224001 new file mode 100644 index 0000000..2c3e64d --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-224001 @@ -0,0 +1,4361 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? 'เลือกการ์ดผู้ต้องสงสัยก่อน แล้วกดเริ่มสืบสวน' + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + accuseBtn.classList.toggle('is-hidden', !isHost); + accuseBtn.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + beginSuspectInvestigation('ชี้ตัวคนร้ายไม่สำเร็จ'); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-224750 b/www/html/Game/public/js/room-lobby.js.bak-20260521-224750 new file mode 100644 index 0000000..6a79004 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-224750 @@ -0,0 +1,4374 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ + function allSuspectsInvestigated() { + return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + const isHost = hostId === socket.id; + const canAccuse = isHost && allSuspectsInvestigated(); + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? (allSuspectsInvestigated() + ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' + : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว + accuseBtn.classList.toggle('is-hidden', !canAccuse); + accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (idx >= 0 && idx <= 2) selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (!allSuspectsInvestigated()) { + appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); + return; + } + // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) — ระบบโหวตกำลังพัฒนาต่อ + appendLobbySystemChat('— Host เปิดขั้นพิจารณาคดี · ระบบโหวตชี้ตัวคนร้ายกำลังพัฒนา'); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-230833 b/www/html/Game/public/js/room-lobby.js.bak-20260521-230833 new file mode 100644 index 0000000..819746c --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-230833 @@ -0,0 +1,4541 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ + let trialMode = null; + let myTrialVote = null; + let trialVoteCounts = [0, 0, 0]; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */ + function injectTrialStyle() { + if (document.getElementById('trial-style')) return; + var st = document.createElement('style'); + st.id = 'trial-style'; + st.textContent = + '.suspect-pick--trial .suspect-pick-actions{display:none!important}' + + '.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' + + '.suspect-card .trial-vote-badge{position:absolute;top:8px;left:8px;background:rgba(34,211,238,.95);color:#06121f;font:700 16px/1 Kanit,system-ui,sans-serif;padding:5px 11px;border-radius:999px;display:none;z-index:6;box-shadow:0 2px 8px rgba(0,0,0,.4)}' + + '.suspect-pick--trial .trial-vote-badge{display:block}' + + '.suspect-card--myvote{outline:3px solid #22d3ee;outline-offset:-3px;border-radius:16px}' + + '.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' + + '#trial-result-banner{position:absolute;left:50%;top:6%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:16px 26px;color:#e7ecff;font:600 21px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' + + '#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}'; + document.head.appendChild(st); + } + + function ensureTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + if (!card.querySelector('.trial-vote-badge')) { + var b = document.createElement('span'); + b.className = 'trial-vote-badge'; + b.textContent = 'โหวต 0'; + card.appendChild(b); + } + } + } + + function applyTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var b = card.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0); + } + } + + function ensureTrialBanner() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-result-banner'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-result-banner'; + el.style.display = 'none'; + ov.appendChild(el); + } + return el; + } + + function ensureTrialRevealBtn() { + var ov = document.getElementById('suspect-pick-overlay'); + var btn = document.getElementById('trial-reveal-btn'); + if (!btn && ov) { + btn = document.createElement('button'); + btn.id = 'trial-reveal-btn'; + btn.type = 'button'; + btn.textContent = 'เปิดเผยผล'; + btn.addEventListener('click', function () { + socket.emit('trial-reveal', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + ov.appendChild(btn); + } + if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none'; + } + + function castTrialVote(idx) { + if (trialMode !== 'voting') return; + myTrialVote = idx; + document.querySelectorAll('.suspect-card').forEach(function (c) { + c.classList.toggle('suspect-card--myvote', parseInt(c.getAttribute('data-index'), 10) === idx); + }); + socket.emit('trial-vote', { index: idx }, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + } + + function openTrialVoteUI(data) { + trialMode = 'voting'; + myTrialVote = null; + trialVoteCounts = [0, 0, 0]; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) suspectProgress = [0, 1, 2].map(function (i) { return Math.min(3, Math.max(0, Math.floor(Number(data.suspectProgress[i])) || 0)); }); + injectTrialStyle(); + var ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + suspectPickOverlayOpen = true; + serverSuspectPhaseActive = false; + ov.classList.remove('is-hidden'); + ov.classList.add('suspect-pick--trial'); + ov.setAttribute('aria-hidden', 'false'); + document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit'); }); + applySuspectProgressVisual(); + ensureTrialBadges(); + applyTrialBadges(); + ensureTrialRevealBtn(); + var banner = ensureTrialBanner(); + if (banner) banner.style.display = 'none'; + var actions = document.getElementById('suspect-pick-actions'); + if (actions) actions.classList.remove('suspect-pick-actions--visible'); + var hint = document.getElementById('suspect-pick-hint'); + if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'พิจารณาคดี — คลิกการ์ดเพื่อโหวตชี้ตัวคนร้าย'; } + updateSuspectFloatingOpenBtn(); + scheduleSuspectPickScale(); + } + + function showTrialResult(data) { + trialMode = 'revealed'; + var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0; + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; + applyTrialBadges(); + document.querySelectorAll('.suspect-card').forEach(function (c) { + c.classList.toggle('suspect-card--culprit', parseInt(c.getAttribute('data-index'), 10) === culprit); + }); + var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก'; + var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0); + var banner = ensureTrialBanner(); + if (banner) { + banner.innerHTML = 'คนร้ายตัวจริงคือ ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '
' + + 'ผู้ตอบถูก: ' + names + (iWon ? '
🎉 คุณตอบถูก! +100' : ''); + banner.style.display = 'block'; + } + var rb = document.getElementById('trial-reveal-btn'); + if (rb) rb.style.display = 'none'; + var hint = document.getElementById('suspect-pick-hint'); + if (hint) hint.textContent = 'ปิดหน้าต่างเพื่อกลับโถง'; + appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ + function allSuspectsInvestigated() { + return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง + const isHost = hostId === socket.id; + const canAccuse = isHost && allSuspectsInvestigated(); + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? (allSuspectsInvestigated() + ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' + : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว + accuseBtn.classList.toggle('is-hidden', !canAccuse); + accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + // รีเซ็ตสถานะพิจารณาคดี + if (trialMode) { + trialMode = null; myTrialVote = null; trialVoteCounts = [0, 0, 0]; + if (ov) ov.classList.remove('suspect-pick--trial'); + document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit'); }); + var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none'; + var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; + } + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + // ===== ขั้นพิจารณาคดี ===== + socket.on('trial-open', (data) => { + openTrialVoteUI(data || {}); + }); + socket.on('trial-vote-update', (data) => { + if (data && Array.isArray(data.counts)) { trialVoteCounts = data.counts; applyTrialBadges(); } + const hint = document.getElementById('suspect-pick-hint'); + if (hint && trialMode === 'voting') { + hint.textContent = 'พิจารณาคดี — คลิกการ์ดเพื่อโหวต (' + ((data && data.voted) || 0) + '/' + ((data && data.totalPlayers) || 0) + ' โหวตแล้ว)'; + } + }); + socket.on('trial-result', (data) => { + showTrialResult(data || {}); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (!(idx >= 0 && idx <= 2)) return; + if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้ + if (trialMode === 'revealed') return; + if (hostId !== socket.id) return; + selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (!allSuspectsInvestigated()) { + appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); + return; + } + // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) + socket.emit('suspect-accuse-open', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-232213 b/www/html/Game/public/js/room-lobby.js.bak-20260521-232213 new file mode 100644 index 0000000..486994d --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-232213 @@ -0,0 +1,4583 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ + let trialMode = null; + let myTrialVote = null; + let trialVoteCounts = [0, 0, 0]; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */ + function injectTrialStyle() { + if (document.getElementById('trial-style')) return; + var st = document.createElement('style'); + st.id = 'trial-style'; + st.textContent = + '.suspect-pick--trial .suspect-pick-actions{display:none!important}' + + '.suspect-pick--trial .suspect-pick-title-img{display:none!important}' + + '#trial-title{display:none;text-align:center;color:#7fe9ff;font:800 40px/1.1 Kanit,system-ui,sans-serif;text-shadow:0 0 18px rgba(34,211,238,.7);margin:0 0 2px}' + + '#trial-title small{display:block;color:#cfe9ff;font:600 18px/1.3 Kanit,system-ui,sans-serif;text-shadow:none;margin-top:6px}' + + '.suspect-pick--trial #trial-title{display:block}' + + '.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' + + // badge อยู่กลางล่างการ์ด (เลียนแบบปุ่ม "เลือกแล้ว" ใน mockup) + '.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);color:#06121f;font:700 15px/1 Kanit,system-ui,sans-serif;padding:7px 16px;border-radius:999px;display:none;z-index:6;white-space:nowrap;box-shadow:0 2px 10px rgba(0,0,0,.45)}' + + // ตอนโหวต: โชว์เฉพาะใบที่เราเลือก = "เลือกแล้ว" (เขียว) + '.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;background:rgba(34,197,94,.97)}' + + // ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ฟ้า) + '.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97)}' + + '.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' + + '.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' + + '#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' + + '#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}'; + document.head.appendChild(st); + } + + function ensureTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + if (!card.querySelector('.trial-vote-badge')) { + var b = document.createElement('span'); + b.className = 'trial-vote-badge'; + b.textContent = ''; + card.appendChild(b); + } + } + } + + function applyTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var b = card.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0); + } + } + + function ensureTrialBanner() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-result-banner'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-result-banner'; + el.style.display = 'none'; + ov.appendChild(el); + } + return el; + } + + function ensureTrialRevealBtn() { + var ov = document.getElementById('suspect-pick-overlay'); + var btn = document.getElementById('trial-reveal-btn'); + if (!btn && ov) { + btn = document.createElement('button'); + btn.id = 'trial-reveal-btn'; + btn.type = 'button'; + btn.textContent = 'เปิดเผยผล'; + btn.addEventListener('click', function () { + socket.emit('trial-reveal', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + ov.appendChild(btn); + } + if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none'; + } + + /** หัวข้อ "พิจารณาคดี" (แทนรูป title เลือกผู้ต้องสงสัยตอนอยู่โหมดโหวต) */ + function ensureTrialTitle() { + var row = document.getElementById('suspect-cards-row'); + var t = document.getElementById('trial-title'); + if (!t && row && row.parentNode) { + t = document.createElement('div'); + t.id = 'trial-title'; + t.innerHTML = 'พิจารณาคดีเลือกผู้ร้ายตัวจริง และส่งตัวไปดำเนินคดี เพื่อทำภารกิจให้สำเร็จ'; + row.parentNode.insertBefore(t, row); + } + return t; + } + + function setTrialSubtitle(txt) { + var t = document.getElementById('trial-title'); + if (!t) return; + var s = t.querySelector('small'); + if (s) s.textContent = txt; + } + + function castTrialVote(idx) { + if (trialMode !== 'voting') return; + myTrialVote = idx; + document.querySelectorAll('.suspect-card').forEach(function (c) { + var sel = parseInt(c.getAttribute('data-index'), 10) === idx; + c.classList.toggle('suspect-card--myvote', sel); + var b = c.querySelector('.trial-vote-badge'); + if (b && sel) b.textContent = '✓ เลือกแล้ว'; + }); + socket.emit('trial-vote', { index: idx }, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + } + + function openTrialVoteUI(data) { + trialMode = 'voting'; + myTrialVote = null; + trialVoteCounts = [0, 0, 0]; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) suspectProgress = [0, 1, 2].map(function (i) { return Math.min(3, Math.max(0, Math.floor(Number(data.suspectProgress[i])) || 0)); }); + injectTrialStyle(); + var ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + suspectPickOverlayOpen = true; + serverSuspectPhaseActive = false; + ov.classList.remove('is-hidden', 'trial-revealed'); + ov.classList.add('suspect-pick--trial'); + ov.setAttribute('aria-hidden', 'false'); + document.querySelectorAll('.suspect-card').forEach(function (c) { + c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected'); + }); + applySuspectProgressVisual(); + ensureTrialTitle(); + ensureTrialBadges(); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + ensureTrialRevealBtn(); + var banner = ensureTrialBanner(); + if (banner) banner.style.display = 'none'; + var actions = document.getElementById('suspect-pick-actions'); + if (actions) actions.classList.remove('suspect-pick-actions--visible'); + var hint = document.getElementById('suspect-pick-hint'); + if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'ลงติกแล้ว : 0/' + ((data && data.totalPlayers) || (peers ? peers.size : 0) || 1); } + updateSuspectFloatingOpenBtn(); + scheduleSuspectPickScale(); + } + + function showTrialResult(data) { + trialMode = 'revealed'; + var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0; + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; + var ov = document.getElementById('suspect-pick-overlay'); + if (ov) ov.classList.add('trial-revealed'); + // โชว์คะแนนโหวตทุกใบ + ไฮไลต์คนร้าย + document.querySelectorAll('.suspect-card').forEach(function (c) { + var ci = parseInt(c.getAttribute('data-index'), 10); + c.classList.remove('suspect-card--myvote'); + c.classList.toggle('suspect-card--culprit', ci === culprit); + var b = c.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[ci]) || 0); + }); + setTrialSubtitle('ผลการพิจารณาคดี'); + var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก'; + var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0); + var banner = ensureTrialBanner(); + if (banner) { + banner.innerHTML = 'คนร้ายตัวจริงคือ ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '
' + + 'ผู้ตอบถูก: ' + names + (iWon ? '
🎉 คุณตอบถูก! +100' : ''); + banner.style.display = 'block'; + } + var rb = document.getElementById('trial-reveal-btn'); + if (rb) rb.style.display = 'none'; + var hint = document.getElementById('suspect-pick-hint'); + if (hint) hint.textContent = 'ปิดหน้าต่างเพื่อกลับโถง'; + appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ + function allSuspectsInvestigated() { + return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง + const isHost = hostId === socket.id; + const canAccuse = isHost && allSuspectsInvestigated(); + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? (allSuspectsInvestigated() + ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' + : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว + accuseBtn.classList.toggle('is-hidden', !canAccuse); + accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + // รีเซ็ตสถานะพิจารณาคดี + if (trialMode) { + trialMode = null; myTrialVote = null; trialVoteCounts = [0, 0, 0]; + if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed'); + document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit'); }); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none'; + var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; + } + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + // ===== ขั้นพิจารณาคดี ===== + socket.on('trial-open', (data) => { + openTrialVoteUI(data || {}); + }); + socket.on('trial-vote-update', (data) => { + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต) + const hint = document.getElementById('suspect-pick-hint'); + if (hint && trialMode === 'voting') { + hint.textContent = 'ลงติกแล้ว : ' + ((data && data.voted) || 0) + '/' + ((data && data.totalPlayers) || 0); + } + }); + socket.on('trial-result', (data) => { + showTrialResult(data || {}); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (!(idx >= 0 && idx <= 2)) return; + if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้ + if (trialMode === 'revealed') return; + if (hostId !== socket.id) return; + selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (!allSuspectsInvestigated()) { + appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); + return; + } + // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) + socket.emit('suspect-accuse-open', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/public/js/room-lobby.js.bak-20260521-234519 b/www/html/Game/public/js/room-lobby.js.bak-20260521-234519 new file mode 100644 index 0000000..d563573 --- /dev/null +++ b/www/html/Game/public/js/room-lobby.js.bak-20260521-234519 @@ -0,0 +1,4608 @@ +(function () { + // Error "message channel closed" มาจาก extension ของเบราว์เซอร์ ไม่ใช่โค้ดเกม — ลองโหมดไม่ระบุตัวตนหรือปิด extension + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const MAIN_LOBBY_AI_BTN_ICON = typeof appPath === 'function' + ? appPath('/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png') + : '/Main-Lobby/IMAGE/BTN-AI-ChatBOT.png'; + const params = new URLSearchParams(location.search); + const spaceId = params.get('space'); + const nick = (params.get('nick') || '').trim() || 'ผู้เล่น'; + const DISPLAY_NAME_STORAGE_KEY = 'roomLobbyDisplayName'; + let profileDisplayName = nick; + if (!spaceId) { location.href = BASE + '/lobby.html'; return; } + const roomIdValueEl = document.getElementById('room-id-value'); + const displayRoomParam = (params.get('displayRoom') || '').trim(); + if (displayRoomParam) { + try { localStorage.setItem('lastCreatedSpaceName', displayRoomParam); } catch (e) { /* ignore */ } + if (roomIdValueEl) roomIdValueEl.textContent = displayRoomParam; + } else if (roomIdValueEl) { + roomIdValueEl.textContent = String(spaceId); + } + + const CREATE_ROOM_URL = typeof appPath === 'function' ? appPath('/Create%20Room/') : '/Create%20Room/'; + + function wasPageReload() { + try { + const entries = performance.getEntriesByType('navigation'); + if (entries && entries.length && entries[0].type === 'reload') return true; + } catch (e) { /* ignore */ } + try { + if (typeof performance !== 'undefined' && performance.navigation && performance.navigation.type === 1) return true; + } catch (e2) { /* ignore */ } + return false; + } + + /** Lobby หลังคดี — ต้องตรงกับ server POST_CASE_LOBBY_SPACE_ID */ + const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; + const PLAYER_KEY = 'jdPlayerKey'; + /** ตรงกับ character.js / Main-Lobby — composite idle ทิศ down */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + const LOBBY_EVIDENCE_ASSET_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE/See%20evidence') : '/Main-Lobby/IMAGE/See%20evidence'; + const LOBBY_EVIDENCE_RARITY = { common: 'Common', rare: 'Rare', legendary: 'Legendary' }; + /** ข้อมูลตัวอย่างตาม caseId — แก้/โหลดจาก API ได้ภายหลัง */ + const LOBBY_EVIDENCE_CASES = { + '1': { + suspects: [ + { linkName: 'สมชาย', cards: [ + { titleTh: 'ก้นบุหรี่เปื้อนลิปสติก', titleEn: 'Lipstick-stained cigarette', body: 'พบใกล้จุดเกิดเหตุ มีคราบลิปสติกสีแดงเข้ม อาจเชื่อมกับ DNA ของผู้ต้องสงสัย', rarity: 'common', stars: 1 }, + { titleTh: 'แว่นตากรอบหัก', titleEn: 'Broken glasses', body: 'กรอบดำแตกหักตามเส้นทางหลบหนี คาดถูกเหยียบขณะวิ่ง', rarity: 'rare', stars: 2 }, + { titleTh: 'ลายนิ้วมือเลือดบนมีด', titleEn: 'Bloody fingerprint on knife', body: 'คราบเลือดปนลายนิ้วมือที่ไม่ตรงกับเหยื่อ — หลักฐานชี้ชัด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'สมหญิง', cards: [ + { titleTh: 'บัตรเข้าลานจอด', titleEn: 'Parking gate log', body: 'เวลาเข้าออกไม่ตรงกับคำให้การเดิม', rarity: 'common', stars: 1 }, + { titleTh: 'ข้อความบนมือถือ', titleEn: 'SMS fragment', body: 'ชวนให้ลบข้อมูลในคลาวด์ก่อนวันเกิดเหตุ', rarity: 'rare', stars: 2 }, + { titleTh: 'กุญแจเข้ารหัส', titleEn: 'Encrypted USB token', body: 'อุปกรณ์รหัสเดียวกับที่พบในเซิร์ฟเวอร์เกม', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'วิชัย', cards: [ + { titleTh: 'หูฟังเกมมิ่ง', titleEn: 'Headset mic trace', body: 'ไมค์ติดเสียงแม็กหน้าร้านขณะเจรจา', rarity: 'common', stars: 1 }, + { titleTh: 'สกรีนล็อกพินผิด', titleEn: 'Failed unlock pattern', body: 'มีความพยายามปลดล็อกรูปแบบเดียวกับเหยื่อ', rarity: 'rare', stars: 2 }, + { titleTh: 'ไฟล์คราสเชอร์', titleEn: 'Crashed session log', body: 'บันทึก RCE จากบัญชีที่ผูกกับไอดีของผู้ต้องสงสัย', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '2': { + suspects: [ + { linkName: 'เปี๊ยก', cards: [ + { titleTh: 'เศษกระจกโชว์เคส', titleEn: 'Display case shard', body: 'ตรงกับรอยแตกที่หน้าร้านอัญมณี', rarity: 'common', stars: 1 }, + { titleTh: 'มือถือหมุดตำแหน่งคลาดเคลื่อน', titleEn: 'GPS mismatch', body: 'แอปแม็ปแสดงว่าอยู่แถวตลาดในช่วงปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'ถุงมือซิลิโคนใช้ครั้งเดียว', titleEn: 'Disposable silicone glove', body: 'ภายในพบผงเพชรจิ๋วเหมือนในร้าน', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'มาลี', cards: [ + { titleTh: 'คูปองรับของ', titleEn: 'Parcel stub', body: 'พัสดุส่งถึงที่พักของผู้ต้องสงสัยในคืนก่อนเหตุ', rarity: 'common', stars: 1 }, + { titleTh: 'กล้องวงจรปิดเบลอ', titleEn: 'Blurred CCTV frame', body: 'เงาร่างสวมหมวกใบเดียวกับที่พบในรถเข็นหลังร้าน', rarity: 'rare', stars: 2 }, + { titleTh: 'แม่แรงเล็ก', titleEn: 'Mini pry bar', body: 'รอยครูดตรงรางกระจกตรงกับเครื่องมือชุดนี้', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'เสก', cards: [ + { titleTh: 'ป้ายหมุดร้านค้า', titleEn: 'Geotagged photo', body: 'โพสต์ SNS ก่อน 30 นาทีที่ประตูร้าน', rarity: 'common', stars: 1 }, + { titleTh: 'ใบเสร็จตัดเลเซอร์', titleEn: 'Laser service receipt', body: 'สั่งตัดกระจกความหนาเฉพาะทางก่อนวันปล้น', rarity: 'rare', stars: 2 }, + { titleTh: 'คำสั่งเช่ารถขนของ', titleEn: 'Van rental order', body: 'ทะเบียนตรงกับคันรถหลบที่ลานกว้าง', rarity: 'legendary', stars: 3 } + ] } + ] + }, + '3': { + suspects: [ + { linkName: 'ธนา', cards: [ + { titleTh: 'ยาหม่องกลิ่นแปลก', titleEn: 'Scented patch', body: 'กลิ่นไม่ตรงกับของในห้อง — อาจติดมาจากที่อื่น', rarity: 'common', stars: 1 }, + { titleTh: 'คีย์การ์ดหมดอายุ', titleEn: 'Expired keycard', body: 'สแกนเข้าตึกหลังเที่ยงคืนได้ทั้งที่ควรถูกระงับ', rarity: 'rare', stars: 2 }, + { titleTh: 'กล้องถ่ายรูปฟิล์ม', titleEn: 'Film camera', body: 'ฟิล์มสุดท้ายเป็นภาพเงาที่ประตูห้องเหยื่อ', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'รุ้ง', cards: [ + { titleTh: 'เมนูร้านกาแฟ', titleEn: 'Coffee sleeve note', body: 'มีเบอร์เขียนด้วยมือเหมือนในโน้ตเหยื่อ', rarity: 'common', stars: 1 }, + { titleTh: 'รอยยางรองเท้า', titleEn: 'Mud sole pattern', body: 'ตรงกับรองเท้ากีฬารุ่นหายาก', rarity: 'rare', stars: 2 }, + { titleTh: 'ผ้าขนหนูเปียก', titleEn: 'Damp towel', body: 'มีคราบสารเคมีทำความสะอาดกระเบื้องเฉพาะจุด', rarity: 'legendary', stars: 3 } + ] }, + { linkName: 'ภูผา', cards: [ + { titleTh: 'เสียงบันทึกสั้น', titleEn: 'Voice memo clip', body: 'ได้ยินเสียงกุญแจตกพื้นก่อนเสียงฉุกเฉิน', rarity: 'common', stars: 1 }, + { titleTh: 'เศษเส้นด้าย', titleEn: 'Fiber snag', body: 'สีเสื้อไม่ตรงกับคนในบ้าน แต่ตรงกับ CCTV ลิฟต์', rarity: 'rare', stars: 2 }, + { titleTh: 'เวลากล้อง CCTV เพี้ยน', titleEn: 'Timestamp drift', body: 'เซิร์ฟเวอร์บันทึกเวลาขาด 7 นาที ช่วงที่เหยื่อหายใจสุดท้าย', rarity: 'legendary', stars: 3 } + ] } + ] + } + }; + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const roomPlayersHud = document.getElementById('room-players-hud'); + const peersList = document.getElementById('peers-list'); + const readyCheck = document.getElementById('ready-check'); + const btnStart = document.getElementById('btn-start'); + const hostOnly = document.getElementById('host-only'); + const nonHostMsg = document.getElementById('non-host-msg'); + const playMapSelect = document.getElementById('play-map-select'); + const canvas = document.getElementById('lobby-map-canvas'); + const READY_IMG_IDLE = SERVER + '/img/btn-ready-idle.png?v=1'; + const READY_IMG_ACTIVE = SERVER + '/img/btn-ready-active.png?v=1'; + + let mapData = null; + let quizModeActive = false; + let quizPhaseLocal = null; + let quizPhaseEndsAt = 0; + let quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let quizTimerInterval = null; + let lastQuizQuestionText = ''; + let lastQuizScores = {}; + /** ผู้เล่นที่ตอบผิดแล้ว — วาดแบบ ghost ให้คนอื่นเห็น (สถานะของตัวเองมาจาก quizPlayerLocal) */ + let quizPeersLocked = {}; + let lastQuizQIdx = 0; + let lastQuizQTotal = 0; + /** mapId ฉากปัจจุบันจากเซิร์ฟ (เช่น mn8nx46h = LobbyB) — ใช้ตรวจ UI LobbyB ให้แม่น */ + let clientLobbyMapId = null; + let hostId = null; + let spaceName = ''; + let maxPlayers = 10; + let lobbyBotSlotCount = 0; + const LOBBY_BOT_PREFIX = '__lobby_bot_'; + const lobbyBots = new Map(); + let suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + /** มินิเกม 3 แบบที่สุ่มแล้ว — ล็อกกับการ์ด 0–2 จนกว่าสร้างห้องใหม่ */ + let suspectCardMinigames = []; + /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ + let suspectProgress = [0, 0, 0]; + /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ + let trialMode = null; + let myTrialVote = null; + let trialVoteCounts = [0, 0, 0]; + let mapBackgroundImg = null; + let hostIconImg = null; + const peers = new Map(); + /** รองรับ x/y string จาก Socket — ไม่งั้นตำแหน่งจากสุ่มจุดเกิดจะถูกทิ้ง */ + function normalizeLobbyPeerFromServer(p, mapRef) { + const sp = mapRef && mapRef.spawn; + const sx = Number(sp && sp.x); + const sy = Number(sp && sp.y); + const fx = Number.isFinite(sx) ? sx : 1; + const fy = Number.isFinite(sy) ? sy : 1; + const x = Number(p.x); + const y = Number(p.y); + const nx = Number.isFinite(x) ? x : fx; + const ny = Number.isFinite(y) ? y : fy; + return { ...p, x: nx, y: ny, tx: nx, ty: ny }; + } + const characterImages = {}; + + /** ลำดับโหลดรูปยืนเฉย/เดิน ต่อทิศ — idle ก่อน (ตรงกับเซิร์ฟ upload) */ + function characterSpriteUrlCandidates(id, dir) { + const d = dir || 'down'; + const enc = encodeURIComponent(id); + const q = '?ch=' + enc; + const base = SERVER + '/img/characters/' + enc + '_' + d; + return [ + base + '_idle.png' + q, + base + '_idle_0.png' + q, + base + '.png' + q, + base + '_0.png' + q, + ]; + } + + 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 CHARACTER_ANIM_FRAMES = 4; + const CHARACTER_ANIM_FRAME_MS = 200; + + 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; + } + + 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 (var k = maxK; k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return k; + } + return -1; + } + + function getCharacterImg(id, direction) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + const urls = characterSpriteUrlCandidates(id, dir); + let uidx = 0; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + return; + } + img.src = urls[uidx]; + }; + img.src = urls[0]; + characterImages[key] = img; + return img; + } + + function getCharacterFrame(id, direction, now, isWalking) { + if (!id) return null; + const dir = direction || 'down'; + const key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(id, dir); + var q = '?ch=' + encodeURIComponent(id); + var tryIdleWalk = characterSpriteUrlCandidates(id, dir); + var frame0 = new Image(); + frame0.onload = function () { + for (var i = 1; i < CHARACTER_ANIM_FRAMES; i++) { + var img = new Image(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_' + i + '.png' + q; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + var tix = 0; + frame0.onerror = function () { + tix += 1; + if (tix >= tryIdleWalk.length) { + if (mapData && canvas) drawLobbyMap(); + return; + } + frame0.src = tryIdleWalk[tix]; + }; + frame0.src = tryIdleWalk[0]; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้รูป idle (_idle.png) ก่อน + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + var 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; + } + + /* ===== Phase 3a: tint ตัวละคร (composite layer + ทาสี) — ตอนนี้ใช้กับตัวผู้เล่นเอง ===== */ + var RL_CUSTOMIZE_ASSET = SERVER + '/img/03-5-Customize/'; + var RL_LAYER_NAMES = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + var myTintTheme = null; + var myTintSkin = null; + var rlSwatchCache = {}; + var tintedCharCache = {}; + var rlLayerMissing = {}; + + function rlLoadImg(src, cb) { + if (rlLayerMissing[src]) { cb(null); return; } // เคย 404 แล้ว ไม่ขอซ้ำ (กัน console spam) + var img = new Image(); + img.onload = function () { cb(img); }; + img.onerror = function () { rlLayerMissing[src] = true; cb(null); }; + img.src = src; + } + + function rlSampleSwatch(group, idx, cb) { + var key = group + '-' + idx; + if (rlSwatchCache[key]) { cb(rlSwatchCache[key]); return; } + rlLoadImg(RL_CUSTOMIZE_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png', function (img) { + if (!img || !img.naturalWidth) { cb(null); return; } + try { + var c = document.createElement('canvas'); c.width = 1; c.height = 1; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, 1, 1); + var d = x.getImageData(0, 0, 1, 1).data; + var rgb = 'rgb(' + d[0] + ',' + d[1] + ',' + d[2] + ')'; + rlSwatchCache[key] = rgb; + cb(rgb); + } catch (e) { cb(null); } + }); + } + + function rlResolveMyColors(cb) { + var c = '', s = ''; + try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} + var need = 0, done = false; + function go() { if (done) return; if (need <= 0) { done = true; if (cb) cb(); } } + if (c) { need++; rlSampleSwatch('color', c, function (rgb) { myTintTheme = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (s) { need++; rlSampleSwatch('skin', s, function (rgb) { myTintSkin = rgb; if (mapData && canvas) drawLobbyMap(); need--; go(); }); } + if (need === 0 && cb) cb(); + } + + /* ===== Preload + Loading overlay ก่อนเข้า room-lobby (กันสีตัวละครกระตุก) ===== */ + var roomJoinReady = false, roomPreloadReady = false, roomLoadingHidden = false; + + function injectRoomLoadingOverlay() { + if (document.getElementById('room-loading-overlay')) return; + var style = document.createElement('style'); + style.textContent = '#room-loading-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:radial-gradient(circle at 50% 38%, #0b1430, #060818);color:#cfe9ff;font-family:Kanit,Sarabun,system-ui,sans-serif;transition:opacity .45s ease}#room-loading-overlay.is-hidden{opacity:0;pointer-events:none}#room-loading-spinner{width:56px;height:56px;border:5px solid rgba(34,211,238,.22);border-top-color:#22d3ee;border-radius:50%;animation:rlspin 1s linear infinite}@keyframes rlspin{to{transform:rotate(360deg)}}#room-loading-overlay .rl-txt{font-size:1.1rem;letter-spacing:.04em;opacity:.9}'; + document.head.appendChild(style); + var ov = document.createElement('div'); + ov.id = 'room-loading-overlay'; + ov.innerHTML = '
กำลังโหลดตัวละคร…
'; + (document.body || document.documentElement).appendChild(ov); + } + + function hideRoomLoading() { + if (roomLoadingHidden) return; + roomLoadingHidden = true; + var ov = document.getElementById('room-loading-overlay'); + if (ov) { ov.classList.add('is-hidden'); setTimeout(function () { if (ov.parentNode) ov.parentNode.removeChild(ov); }, 600); } + } + + function maybeHideRoomLoading() { if (roomJoinReady && roomPreloadReady) hideRoomLoading(); } + + function preloadMyTintedCharacter(cb) { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) { if (cb) cb(); return; } + var dirs = ['down', 'up', 'left', 'right']; + var suffixes = ['idle', '0', '1', '2', '3']; + var total = dirs.length * suffixes.length, doneCount = 0, finished = false; + function one() { doneCount++; if (!finished && doneCount >= total) { finished = true; if (cb) cb(); } } + setTimeout(function () { if (!finished) { finished = true; if (cb) cb(); } }, 4500); + dirs.forEach(function (dir) { + var ckey = id + '|' + (myTintTheme || '') + '|' + (myTintSkin || '') + '|' + dir; + var anim = tintedCharCache[ckey] || (tintedCharCache[ckey] = { frames: [], fallback: null }); + suffixes.forEach(function (suf) { + rlBuildTintedFrame(id, dir, suf, myTintTheme, myTintSkin, function (img) { + if (img) { if (suf === 'idle') anim.fallback = img; else anim.frames[parseInt(suf, 10)] = img; } + one(); + }); + }); + }); + } + + /* ===== ห้องแต่งตัว (Customize) ในห้อง room-lobby — ปุ่มล่างซ้าย + popup + tint สด ===== */ + var ROOM_CZ_ASSET = SERVER + '/img/03-5-Customize/'; + var ROOM_CZ_FACE = [ + { idx: 1, price: 0 }, { idx: 2, price: 0 }, { idx: 3, price: 50 }, { idx: 4, price: 50 }, + { idx: 5, price: 50 }, { idx: 6, price: 100 }, { idx: 7, price: 100 }, { idx: 8, price: 200 } + ]; + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ + var ROOM_CZ_SPOT = null; + var roomCzIcon = new Image(); + roomCzIcon.src = '/Main-Lobby/IMAGE/btn-cloth.png'; + roomCzIcon.onload = function () { if (typeof mapData !== 'undefined' && mapData && canvas) drawLobbyMap(); }; + + function markRoomCzInteractiveCell() { + if (!mapData) return; + if (mapData.customizeSpot && Number.isFinite(Number(mapData.customizeSpot.x)) && Number.isFinite(Number(mapData.customizeSpot.y))) { + ROOM_CZ_SPOT = { x: Math.floor(Number(mapData.customizeSpot.x)), y: Math.floor(Number(mapData.customizeSpot.y)) }; + } else { + ROOM_CZ_SPOT = null; // ไม่มีจุดแต่งตัวใน map นี้ → ไม่แสดง icon / ไม่มี interact + return; + } + var w = mapData.width || 20, h = mapData.height || 15; + if (ROOM_CZ_SPOT.x < 0 || ROOM_CZ_SPOT.x >= w || ROOM_CZ_SPOT.y < 0 || ROOM_CZ_SPOT.y >= h) { ROOM_CZ_SPOT = null; return; } + if (!Array.isArray(mapData.interactive)) mapData.interactive = []; + for (var y = 0; y < h; y++) { if (!Array.isArray(mapData.interactive[y])) mapData.interactive[y] = []; } + mapData.interactive[ROOM_CZ_SPOT.y][ROOM_CZ_SPOT.x] = 1; + } + + function isRoomCzInteractTarget(t) { + return !!(t && ROOM_CZ_SPOT && t.x === ROOM_CZ_SPOT.x && t.y === ROOM_CZ_SPOT.y); + } + + function isNearRoomCzSpot(me) { + if (!me || !ROOM_CZ_SPOT || typeof me.x !== 'number' || typeof me.y !== 'number') return false; + var dx = me.x - ROOM_CZ_SPOT.x, dy = me.y - ROOM_CZ_SPOT.y; + return (dx * dx + dy * dy) <= 2.25; // ภายในรัศมี ~1.5 ช่อง (ยืนใกล้ตู้ก็กด F ได้) + } + + function roomCzInjectStyle() { + if (document.getElementById('room-cz-style')) return; + var st = document.createElement('style'); + st.id = 'room-cz-style'; + st.textContent = [ + '.room-cz-open{position:fixed;left:clamp(10px,1.4vw,22px);bottom:clamp(96px,15vh,150px);z-index:60;width:clamp(54px,6vw,76px);padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-open img{display:block;width:100%;height:auto;filter:drop-shadow(0 0 8px rgba(34,211,238,.5))}', + '.room-cz-overlay{position:fixed;inset:0;z-index:140;display:flex;align-items:center;justify-content:center;padding:16px;font-family:Kanit,Sarabun,system-ui,sans-serif}', + '.room-cz-overlay.hidden{display:none!important}', + '.room-cz-backdrop{position:absolute;inset:0;background:rgba(4,6,18,.78);backdrop-filter:blur(4px)}', + '.room-cz-dialog{position:relative;width:min(92vw,720px);max-height:90vh;overflow:hidden auto;display:flex;flex-direction:column;gap:clamp(.5rem,1.6vh,1rem);padding:clamp(1rem,3vw,1.6rem) clamp(1rem,3vw,1.8rem) clamp(1.2rem,3vw,1.8rem);border-radius:18px;background:linear-gradient(180deg,rgba(14,20,44,.97),rgba(9,13,30,.97));border:2px solid rgba(34,211,238,.7);box-shadow:0 0 22px rgba(34,211,238,.45),0 0 48px rgba(236,72,153,.28),inset 0 0 24px rgba(34,211,238,.1);color:#e8faff}', + '.room-cz-titlebar{display:flex;align-items:center;justify-content:center;position:relative}', + '.room-cz-titlebar h2{margin:0;font-size:clamp(1.1rem,3vw,1.6rem);font-weight:700;text-shadow:0 0 12px rgba(34,211,238,.6)}', + '.room-cz-close{position:absolute;right:0;top:0;width:38px;height:38px;border:2px solid rgba(236,72,153,.6);border-radius:10px;background:rgba(20,16,40,.6);color:#ff8fd0;font-size:1.4rem;line-height:1;cursor:pointer}', + '.room-cz-row{display:flex;align-items:center;gap:clamp(.5rem,2vw,1rem);flex-wrap:wrap}', + '.room-cz-rowlabel{height:clamp(20px,3.4vh,30px);width:auto;flex:0 0 auto}', + '.room-cz-swatches{display:flex;flex-wrap:wrap;gap:clamp(6px,1vw,10px)}', + '.room-cz-swatch{padding:0;border:2px solid transparent;border-radius:8px;background:none;cursor:pointer;line-height:0}', + '.room-cz-swatch img{display:block;width:clamp(28px,4vw,40px);height:auto;border-radius:6px}', + '.room-cz-swatch.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.7)}', + '.room-cz-tabs{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:776/118}', + '.room-cz-tabbar{display:block;width:100%;height:100%;object-fit:contain;pointer-events:none}', + '.room-cz-tabhit{position:absolute;top:0;height:100%;width:33.34%;padding:0;border:none;background:transparent;cursor:pointer}', + '.room-cz-tabhit[data-tab=face]{left:0}.room-cz-tabhit[data-tab=hair]{left:33.33%}.room-cz-tabhit[data-tab=cloth]{left:66.66%}', + '.room-cz-items{flex:1;min-height:clamp(140px,30vh,280px);max-height:42vh;overflow-y:auto;display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(8px,1.5vw,14px);padding:clamp(8px,1.5vw,14px);border-radius:12px;background:rgba(8,12,28,.6);border:1px solid rgba(34,211,238,.25)}', + '.room-cz-item{position:relative;padding:6px;border:2px solid rgba(34,211,238,.3);border-radius:12px;background:rgba(18,26,52,.7);cursor:pointer;aspect-ratio:1;display:flex;align-items:center;justify-content:center}', + '.room-cz-item.sel{border-color:#22d3ee;box-shadow:0 0 10px rgba(34,211,238,.6)}', + '.room-cz-item img{width:78%;height:auto;object-fit:contain}', + '.room-cz-item .pr{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:.7rem;font-weight:700;color:#ffe066;background:rgba(0,0,0,.45);border-radius:8px;padding:1px 8px}', + '.room-cz-empty{grid-column:1/-1;align-self:center;text-align:center;color:rgba(255,255,255,.6);padding:2rem 1rem}', + '.room-cz-confirm{align-self:center;padding:0;border:none;background:none;cursor:pointer}', + '.room-cz-confirm img{display:block;width:min(60vw,240px);height:auto}', + '@media(max-width:560px){.room-cz-items{grid-template-columns:repeat(3,1fr)}}' + ].join(''); + document.head.appendChild(st); + } + + function roomCzBuildDom() { + if (document.getElementById('room-cz-overlay')) return; + var ov = document.createElement('div'); + ov.id = 'room-cz-overlay'; ov.className = 'room-cz-overlay hidden'; + ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'ห้องแต่งตัว'); + ov.innerHTML = + '
' + + '
' + + '

ห้องแต่งตัว

' + + '
ธีมสี
' + + '
สีผิว
' + + '
' + + '
' + + '' + + '
'; + document.body.appendChild(ov); + } + + function roomCzMarkSwatch(group, idx) { + var wrap = document.getElementById(group === 'color' ? 'room-cz-colors' : 'room-cz-skins'); + if (wrap) [].forEach.call(wrap.children, function (c) { c.classList.toggle('sel', c.getAttribute('data-idx') === String(idx)); }); + } + + function roomCzSelectSwatch(group, idx) { + roomCzMarkSwatch(group, idx); + try { localStorage.setItem(group === 'color' ? 'lobbyThemeColor' : 'lobbySkinTone', String(idx)); } catch (e) {} + rlResolveMyColors(function () { updateRoomProfileAvatarTinted(); if (mapData && canvas) drawLobbyMap(); }); + } + + function roomCzMakeSwatch(group, idx) { + var b = document.createElement('button'); + b.type = 'button'; b.className = 'room-cz-swatch'; b.setAttribute('data-idx', String(idx)); + var im = document.createElement('img'); + im.src = ROOM_CZ_ASSET + (group === 'color' ? 'color-' : 'skin-tone-') + idx + '.png'; im.alt = ''; + b.appendChild(im); + b.addEventListener('click', function () { roomCzSelectSwatch(group, idx); }); + return b; + } + + function roomCzRenderItems(tab) { + var grid = document.getElementById('room-cz-items'); if (!grid) return; + grid.innerHTML = ''; + if (tab !== 'face') { var e = document.createElement('div'); e.className = 'room-cz-empty'; e.textContent = 'ยังไม่เปิดให้บริการ'; grid.appendChild(e); return; } + var saved = ''; try { saved = localStorage.getItem('lobbyItem_face') || ''; } catch (e2) {} + ROOM_CZ_FACE.forEach(function (it) { + var cell = document.createElement('button'); cell.type = 'button'; + cell.className = 'room-cz-item' + (String(it.idx) === saved ? ' sel' : ''); cell.setAttribute('data-idx', String(it.idx)); + var im = document.createElement('img'); im.src = ROOM_CZ_ASSET + 'face-' + it.idx + '.png'; im.alt = ''; cell.appendChild(im); + if (it.price > 0) { var p = document.createElement('span'); p.className = 'pr'; p.textContent = String(it.price); cell.appendChild(p); } + cell.addEventListener('click', function () { + [].forEach.call(grid.children, function (c) { c.classList.remove('sel'); }); + cell.classList.add('sel'); + try { localStorage.setItem('lobbyItem_face', String(it.idx)); } catch (e3) {} + }); + grid.appendChild(cell); + }); + } + + function roomCzSetTab(tab) { + var bar = document.getElementById('room-cz-tabbar'); + if (bar) { var m = { face: 'tab-1-face.png', hair: 'tab-2-hair.png', cloth: 'tab-3-cloth.png' }; bar.src = ROOM_CZ_ASSET + (m[tab] || m.face); } + roomCzRenderItems(tab); + } + + function openRoomCustomize() { + var ov = document.getElementById('room-cz-overlay'); if (!ov) return; + var cw = document.getElementById('room-cz-colors'), sw = document.getElementById('room-cz-skins'); + if (cw && !cw.childElementCount) { for (var i = 1; i <= 8; i++) cw.appendChild(roomCzMakeSwatch('color', i)); } + if (sw && !sw.childElementCount) { for (var j = 1; j <= 3; j++) sw.appendChild(roomCzMakeSwatch('skin', j)); } + try { + var c = localStorage.getItem('lobbyThemeColor'); if (c) roomCzMarkSwatch('color', c); + var s = localStorage.getItem('lobbySkinTone'); if (s) roomCzMarkSwatch('skin', s); + } catch (e) {} + roomCzSetTab('face'); + ov.classList.remove('hidden'); + } + + function closeRoomCustomize() { var ov = document.getElementById('room-cz-overlay'); if (ov) ov.classList.add('hidden'); } + + function setupRoomCustomize() { + roomCzInjectStyle(); + roomCzBuildDom(); + var ob = document.getElementById('room-cz-open'); + if (ob) ob.addEventListener('click', openRoomCustomize); + var cb = document.getElementById('room-cz-close'); + if (cb) cb.addEventListener('click', closeRoomCustomize); + var bd = document.getElementById('room-cz-backdrop'); + if (bd) bd.addEventListener('click', closeRoomCustomize); + var cf = document.getElementById('room-cz-confirm'); + if (cf) cf.addEventListener('click', closeRoomCustomize); + [].forEach.call(document.querySelectorAll('.room-cz-tabhit'), function (t) { + t.addEventListener('click', function () { roomCzSetTab(t.getAttribute('data-tab')); }); + }); + } + + function rlTintMask(img, color) { + var c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + var x = c.getContext('2d'); + x.drawImage(img, 0, 0); + x.globalCompositeOperation = 'source-in'; + x.fillStyle = color; + x.fillRect(0, 0, c.width, c.height); + return c; + } + + var rlCharManifest = null; + var rlManifestId = null; + /** ตัวละคร default — ใช้เมื่อผู้เล่น/บอทไม่มี characterId (กันตัวละครหายเป็นวงกลม blob) */ + var rlDefaultCharId = ''; + (function rlLoadDefaultChar() { + try { + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (list) { + if (!Array.isArray(list) || !list.length) return; + var pick = list.filter(function (x) { return x && x.hasLayerFiles; })[0] || list[0]; + if (pick && pick.id) { + rlDefaultCharId = pick.id; + if (typeof mapData !== 'undefined' && mapData && typeof canvas !== 'undefined' && canvas) drawLobbyMap(); + } + }) + .catch(function () { /* ignore */ }); + } catch (e) { /* ignore */ } + })(); + + function rlEnsureManifest(cb) { + var id = getStoredCharacterId(); + if (!id) { if (cb) cb(null); return; } + if (rlManifestId === id && rlCharManifest) { if (cb) cb(rlCharManifest); return; } + fetch(SERVER + '/api/characters', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (list) { + var c = Array.isArray(list) ? list.filter(function (x) { return x && x.id === id; })[0] : null; + if (c && c.layerManifest) { rlCharManifest = c.layerManifest; rlManifestId = id; if (cb) cb(rlCharManifest); } + else if (cb) cb(null); + }) + .catch(function () { if (cb) cb(null); }); + } + + function rlFrameLayerMap(id, dir, frameSuffix) { + if (!rlCharManifest || rlManifestId !== id) return null; + var entry = (frameSuffix === 'idle') + ? (rlCharManifest.byDirIdle && rlCharManifest.byDirIdle[dir]) + : (rlCharManifest.byDir && rlCharManifest.byDir[dir]); + if (!entry || !entry.frames || !entry.frames.length) return null; + if (frameSuffix === 'idle') return entry.frames[0] || null; + var fi = parseInt(frameSuffix, 10); + return entry.frames[fi] || entry.frames[0] || null; + } + + function rlBuildTintedFrame(id, dir, frameSuffix, theme, skin, cb) { + var map = rlFrameLayerMap(id, dir, frameSuffix); + if (!map) { cb(null); return; } + var names = RL_LAYER_NAMES.filter(function (n) { return map[n]; }); + if (!names.length) { cb(null); return; } + var loaded = {}, pending = names.length; + names.forEach(function (name) { + rlLoadImg(SERVER + '/img/characters/' + map[name], function (img) { + loaded[name] = img; + if (--pending === 0) done(); + }); + }); + function done() { + var ref = null, i; + for (i = 0; i < RL_LAYER_NAMES.length; i++) { if (loaded[RL_LAYER_NAMES[i]]) { ref = loaded[RL_LAYER_NAMES[i]]; break; } } + if (!ref) { cb(null); return; } + var c = document.createElement('canvas'); c.width = ref.naturalWidth; c.height = ref.naturalHeight; + var x = c.getContext('2d'); + RL_LAYER_NAMES.forEach(function (name) { + var img = loaded[name]; + if (!img || !img.naturalWidth) return; + if (theme && (name === 'bodyColor' || name === 'hairColor')) x.drawImage(rlTintMask(img, theme), 0, 0); + else if (skin && name === 'headColor') x.drawImage(rlTintMask(img, skin), 0, 0); + else x.drawImage(img, 0, 0); + }); + var out = new Image(); + try { out.src = c.toDataURL('image/png'); } catch (e) { cb(null); return; } + cb(out); + } + } + + function getTintedFrame(id, theme, skin, dir, now, isWalking) { + var ckey = id + '|' + (theme || '') + '|' + (skin || '') + '|' + dir; + var anim = tintedCharCache[ckey]; + if (!anim) { + anim = { frames: [], fallback: null }; + tintedCharCache[ckey] = anim; + rlBuildTintedFrame(id, dir, 'idle', theme, skin, function (img) { if (img) anim.fallback = img; if (mapData && canvas) drawLobbyMap(); }); + for (var i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + (function (idx) { + rlBuildTintedFrame(id, dir, String(idx), theme, skin, function (img) { if (img) anim.frames[idx] = img; if (mapData && canvas) drawLobbyMap(); }); + })(i); + } + } + var phase = walkAnimPhaseIndex(now, isWalking); + // ยืนนิ่ง → ใช้เฟรม idle (byDirIdle) ก่อน ไม่ใช่เฟรมเดินเฟรมแรก + if (!isWalking && anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + for (var k = Math.min(phase, CHARACTER_ANIM_FRAMES - 1); k >= 0; k--) { + var f = anim.frames[k]; + if (f && f.complete && f.naturalWidth) return f; + } + if (anim.fallback && anim.fallback.complete && anim.fallback.naturalWidth) return anim.fallback; + return null; + } + + function getAvatarImgColored(characterId, theme, skin, direction, now, isWalking) { + if (characterId && (theme || skin)) { + var t = getTintedFrame(characterId, theme || null, skin || null, direction || 'down', now, isWalking); + if (t) return t; + } + return getAvatarImg(characterId, direction, now, isWalking); + } + + function updateRoomProfileAvatarTinted() { + var id = getStoredCharacterId(); + if (!id || (!myTintTheme && !myTintSkin)) return; + rlBuildTintedFrame(id, 'down', 'idle', myTintTheme, myTintSkin, function (img) { + if (!img) return; + var av = document.getElementById('room-lobby-profile-avatar'); + if (av) av.src = img.src; + }); + } + + injectRoomLoadingOverlay(); + setTimeout(hideRoomLoading, 8000); // safety: ไม่บัง overlay เกิน 8 วิ + + const keys = {}; + const MOVE_SPEED = 0.15; + let lastMoveSend = 0; + const moveCodes = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + let lobbyInteractPulse = null; + let lobbyZoom = 1.2; + const LOBBY_ZOOM_MIN = 0.4; + const LOBBY_ZOOM_MAX = 2.5; + let mapTransform = { offsetX: 0, offsetY: 0, ts: 32, w: 20, h: 15 }; + /** true เฉพาะช่องพิมพ์ข้อความ — ไม่รวม READY / แผนที่ (ให้กด F โต้ตอบได้โดยไม่ต้องกดพร้อมก่อน) */ + function isChatFocused() { + const el = document.activeElement; + if (!el) return false; + if (el.id === 'ready-check') return false; + if (el.closest && el.closest('.room-lobby-ready-fixed')) return false; + if (el.id === 'lobby-map-canvas') return false; + if (el.id === 'chat-input' || el.id === 'ai-chat-input') return true; + if (el.tagName === 'TEXTAREA') return true; + if (el.tagName === 'INPUT') { + const t = (el.type || '').toLowerCase(); + return t === 'text' || t === 'search' || t === 'tel' || t === 'url' || t === 'email' || t === 'password' || t === ''; + } + if (el.isContentEditable) return true; + return false; + } + + /** ปุ่มเดียวกับ F บนแป้น US + แป้นไทยมาตรฐาน (ช่องเดียวกับ F → ด) + ยังทำงานระหว่างสลับภาษา */ + function isLobbyInteractKeyDown(e) { + if (e.isComposing || e.keyCode === 229) return false; + if (e.code === 'KeyF') return true; + const k = e.key; + if (k === 'f' || k === 'F') return true; + if (k === 'ด') return true; + return false; + } + + hostIconImg = new Image(); + hostIconImg.src = SERVER + '/img/host-icon.png'; + hostIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const lobbyReadyIconImg = new Image(); + lobbyReadyIconImg.src = SERVER + '/img/icon-ready.png?v=1'; + lobbyReadyIconImg.onerror = function () { + lobbyReadyIconImg.src = SERVER + '/img/lobby-icon-ready.png?v=1'; + }; + lobbyReadyIconImg.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + + const readyLabelImg = document.getElementById('ready-label-img'); + const readyLabelEl = document.getElementById('ready-label'); + /** พร้อม/ไม่พร้อม ใช้ได้ทุกจำนวนคนในห้อง — ไม่ล็อกตาม peers.size */ + function ensureReadyControlEnabled() { + if (readyCheck) readyCheck.disabled = false; + } + + function updateReadyLabelVisual() { + if (!readyCheck || !readyLabelImg) return; + var on = readyCheck.checked; + readyLabelImg.src = on ? READY_IMG_ACTIVE : READY_IMG_IDLE; + readyLabelImg.alt = on ? 'พร้อมแล้ว — กดเพื่อยกเลิก' : 'ยังไม่พร้อม — กดเพื่อพร้อม'; + if (readyLabelEl) readyLabelEl.classList.toggle('ready-label--active', on); + } + + const defaultShadowImgs = { up: new Image(), down: new Image(), left: new Image(), right: new Image() }; + ['up', 'down', 'left', 'right'].forEach(function (d) { + defaultShadowImgs[d].src = SERVER + '/img/default-shadow-' + d + '.png'; + defaultShadowImgs[d].onload = function () { if (mapData && canvas) drawLobbyMap(); }; + }); + function getDefaultShadowImg(dir) { + const d = dir || 'down'; + return defaultShadowImgs[d] && defaultShadowImgs[d].complete && defaultShadowImgs[d].naturalWidth ? defaultShadowImgs[d] : null; + } + + const speakingBubbleFrames = []; + const SPEAKING_BUBBLE_FRAME_MS = 120; + for (var sb = 0; sb < 4; sb++) { + var img = new Image(); + img.src = SERVER + '/img/speaking-bubble-0' + sb + '.png'; + img.onload = function () { if (mapData && canvas) drawLobbyMap(); }; + speakingBubbleFrames.push(img); + } + function getSpeakingBubbleFrame() { + var idx = Math.floor((typeof Date !== 'undefined' ? Date.now() : 0) / SPEAKING_BUBBLE_FRAME_MS) % 4; + var img = speakingBubbleFrames[idx]; + return img && img.complete && img.naturalWidth ? img : (speakingBubbleFrames[0] && speakingBubbleFrames[0].complete ? speakingBubbleFrames[0] : null); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + if (!grid || !grid.length) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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 }; + } + + function syncQuizMapQuestionPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl) return; + const bounds = (quizModeActive && mapData && mapData.gameType === 'quiz' && (lastQuizQuestionText || '').trim()) + ? getQuizQuestionAreaTileBounds(mapData) + : null; + if (!bounds || !mapTransform || mapTransform.cw == null) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const cw = mapTransform.cw; + const ch = mapTransform.ch; + const z = mapTransform.lobbyZoom; + const ts = mapTransform.tileSize; + const meX = mapTransform.meX; + const meY = mapTransform.meY; + const leftPx = cw / 2 + z * (bounds.minX * ts - meX * ts); + const topPx = ch / 2 + z * (bounds.minY * ts - meY * ts); + const wPx = z * (bounds.maxX - bounds.minX + 1) * ts; + const hPx = z * (bounds.maxY - bounds.minY + 1) * ts; + textEl.textContent = lastQuizQuestionText || ''; + panel.style.left = Math.round(leftPx) + 'px'; + panel.style.top = Math.round(topPx) + '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'); + } + + function drawLobbyMap() { + if (!canvas || !mapData) return; + const ctx = canvas.getContext('2d'); + const w = mapData.width || 20; + const h = mapData.height || 15; + const tileSize = mapData.tileSize || 32; + const mapWpx = w * tileSize; + const mapHpx = h * tileSize; + const cw = canvas.width; + const ch = canvas.height; + const characterCells = mapData.characterCells || 1; + const me = peers.get(socket.id); + const meX = (me && typeof me.x === 'number') ? me.x : 1; + const meY = (me && typeof me.y === 'number') ? me.y : 1; + const ts = tileSize * lobbyZoom; + mapTransform = { cw, ch, lobbyZoom, tileSize, meX, meY, w, h }; + + ctx.fillStyle = '#303770'; + ctx.fillRect(0, 0, cw, ch); + + ctx.save(); + ctx.translate(cw / 2, ch / 2); + ctx.scale(lobbyZoom, lobbyZoom); + ctx.translate(-meX * tileSize, -meY * tileSize); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + const nw = mapBackgroundImg.naturalWidth; + const nh = mapBackgroundImg.naturalHeight; + ctx.drawImage(mapBackgroundImg, 0, 0, nw, nh, 0, 0, mapWpx, mapHpx); + } + + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + if (showGrid) { + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x * tileSize; + const sy = y * tileSize; + const ob = mapData.objects?.[y]?.[x] ?? 0; + const cellColor = mapData.cellColors && mapData.cellColors[y] && mapData.cellColors[y][x]; + if (ob === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.92)'; + ctx.fillRect(sx, sy, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(sx, sy, tileSize, tileSize); + } else if (cellColor) { + ctx.fillStyle = cellColor; + ctx.fillRect(sx, sy, tileSize, tileSize); + } else if (!mapBackgroundImg || !mapBackgroundImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(158,206,106,0.8)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + const pulse = lobbyInteractPulse; + if (pulse && pulse.x === x && pulse.y === y && timeMs < pulse.until) { + const t = (pulse.until - timeMs) / 700; + ctx.fillStyle = 'rgba(255, 214, 102,' + (0.25 + 0.35 * t) + ')'; + ctx.fillRect(sx, sy, tileSize, tileSize); + } + } + const isStartArea = mapData.gameType === 'lobby' && mapData.startGameArea && mapData.startGameArea[y] && mapData.startGameArea[y][x] === 1; + if (isStartArea) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.4)'; + ctx.fillRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + const isQuizQ = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.78)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizT = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + const isQuizF = mapData.gameType === 'quiz' && 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, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.85)'; + ctx.strokeRect(sx + 2, sy + 2, tileSize - 4, tileSize - 4); + } + } + } + } + if (mapData.gameType === 'quiz' && showGrid) { + function tileBoundsForGrid(gr) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (let yy = 0; yy < h; yy++) { + for (let xx = 0; xx < w; xx++) { + if (gr && gr[yy] && gr[yy][xx] === 1) { + minX = Math.min(minX, xx); + maxX = Math.max(maxX, xx); + minY = Math.min(minY, yy); + maxY = Math.max(maxY, yy); + } + } + } + return minX === Infinity ? null : { minX, maxX, minY, maxY }; + } + function drawQuizZoneLabel(gr, en, th, icon, stroke, fill) { + const b = tileBoundsForGrid(gr); + if (!b) return; + const sx = b.minX * tileSize; + const sy = b.minY * tileSize; + const sw = (b.maxX - b.minX + 1) * tileSize; + const sh = (b.maxY - b.minY + 1) * tileSize; + const mcx = sx + sw / 2; + const mcy = sy + sh / 2; + ctx.save(); + ctx.shadowColor = stroke; + ctx.shadowBlur = 16; + ctx.strokeStyle = stroke; + ctx.lineWidth = 3; + ctx.strokeRect(sx + 1, sy + 1, sw - 2, sh - 2); + ctx.shadowBlur = 0; + const iconSz = Math.min(sw, sh) * 0.4; + ctx.font = 'bold ' + Math.round(iconSz) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = fill; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(icon, mcx, mcy - sh * 0.1); + ctx.font = 'bold ' + Math.max(11, Math.round(tileSize * 0.44)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(en, mcx, mcy + sh * 0.08); + ctx.font = Math.max(10, Math.round(tileSize * 0.32)) + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(226,232,240,0.92)'; + ctx.fillText(th, mcx, mcy + sh * 0.2); + ctx.restore(); + } + drawQuizZoneLabel(mapData.quizTrueArea, 'SAFE', 'ปลอดภัย', '✓', 'rgba(122,220,255,0.95)', 'rgba(200,240,255,0.95)'); + drawQuizZoneLabel(mapData.quizFalseArea, 'SCAM', 'อันตราย', '✕', 'rgba(255,130,200,0.95)', 'rgba(255,210,225,0.95)'); + } + function peerVisualOffset(id) { + if (mapData && mapData.lobbySpawnMode === 'slots6') return { ax: 0, ay: 0 }; + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const ax = ((h % 5) - 2) * 0.1; + const ay = ((Math.floor(h / 5) % 5) - 2) * 0.1; + return { ax, ay }; + } + + // ไอคอน "ห้องแต่งตัว" ในฉาก (จุด interact — เดินไปกด F) + if (ROOM_CZ_SPOT && roomCzIcon && roomCzIcon.complete && roomCzIcon.naturalWidth) { + const _czx = ROOM_CZ_SPOT.x * tileSize; + const _czy = ROOM_CZ_SPOT.y * tileSize; + const _czS = tileSize * 1.5; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = 'rgba(34,211,238,0.35)'; + ctx.beginPath(); + ctx.ellipse(_czx + tileSize / 2, _czy + tileSize - 3, tileSize * 0.55, tileSize * 0.22, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + ctx.drawImage(roomCzIcon, _czx + tileSize / 2 - _czS / 2, _czy + tileSize - _czS, _czS, _czS); + } + + const peerList = [...peers.entries(), ...lobbyBots.entries()].sort(function (a, b) { + const pa = a[1], pb = b[1]; + const ya = pa.y != null ? pa.y : 1, yb = pb.y != null ? pb.y : 1; + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return (pa.x != null ? pa.x : 1) - (pb.x != null ? pb.x : 1); + }); + peerList.forEach(function (entry) { + const id = entry[0], p = entry[1]; + const off = peerVisualOffset(id); + const px = ((p.x != null ? p.x : 1) + off.ax) * tileSize; + const py = ((p.y != null ? p.y : 1) + off.ay) * tileSize; + const screenX = px; + const screenY = py; + const cx = screenX + tileSize / 2; + const cellBottom = screenY + tileSize; + const boxSize = Math.min(tileSize, tileSize * 1.2) * characterCells; + const dir = p.direction || 'down'; + const isWalking = id === socket.id + ? !!(me && me.isWalking) + : (p.tx != null || p.ty != null) + ? !!((p.tx != null && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)) + : !!p.isWalking; /* บอท (ไม่มี tx/ty) ใช้ flag isWalking ที่ stepLobbyCaseBots ตั้ง */ + const peerLockedOut = quizModeActive && ( + quizPeersLocked[id] || + (id === socket.id && quizPlayerLocal && quizPlayerLocal.cannotTrue && quizPlayerLocal.cannotFalse) + ); + if (peerLockedOut) ctx.save(); + if (peerLockedOut) { + ctx.globalAlpha = 0.45; + ctx.filter = 'grayscale(1) brightness(1.25)'; + } + const peerTheme = (id === socket.id) ? myTintTheme : (p.colorTheme || null); + const peerSkin = (id === socket.id) ? myTintSkin : (p.colorSkin || null); + const cid = p.characterId || rlDefaultCharId; // กันตัวละครหายเป็นวงกลม blob เมื่อ characterId ว่าง + const charImg = getAvatarImgColored(cid, peerTheme, peerSkin, dir, timeMs, isWalking); + const iw = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalWidth : 0; + const ih = charImg && charImg.complete && charImg.naturalWidth ? charImg.naturalHeight : 0; + const imgScale = (iw && ih) ? Math.min(boxSize / iw, boxSize / ih, 1) : 1; + const drawW = (iw || boxSize) * imgScale; + const drawH = (ih || boxSize) * imgScale; + const drawY = cellBottom - drawH; + const shadowImg = getDefaultShadowImg(dir); + if (shadowImg) { + const sw = shadowImg.naturalWidth; + const sh = shadowImg.naturalHeight; + const shadowScale = Math.min(drawW / sw, drawH / sh, 1.2); + const shadowW = sw * shadowScale; + const shadowH = sh * shadowScale; + const shadowY = cellBottom - shadowH + (tileSize * 0.08); + ctx.globalAlpha = 0.7; + ctx.drawImage(shadowImg, 0, 0, sw, sh, cx - shadowW / 2, shadowY, shadowW, shadowH); + ctx.globalAlpha = 1; + } + if (charImg.complete && charImg.naturalWidth) { + ctx.drawImage(charImg, 0, 0, iw, ih, cx - drawW / 2, drawY, drawW, drawH); + } else { + const r = Math.max(8, ts / 2 - 2); + const cy = screenY + ts / 2; + ctx.fillStyle = id === socket.id ? '#7aa2f7' : '#9ece6a'; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#c0caf5'; + ctx.lineWidth = 2; + ctx.stroke(); + } + const nameFontSize = Math.max(10, Math.round(tileSize * 0.4)); + const labelY = cellBottom - drawH - (tileSize * 0.2); + const headTop = cellBottom - drawH - 4; + const now = Date.now(); + if (p.speakingUntil > now) { + var bubbleImg = getSpeakingBubbleFrame(); + if (bubbleImg) { + var bw = tileSize * 0.9; + var bh = (bubbleImg.naturalHeight / bubbleImg.naturalWidth) * bw; + var bubbleY = headTop - bh - tileSize * 0.08; + ctx.drawImage(bubbleImg, 0, 0, bubbleImg.naturalWidth, bubbleImg.naturalHeight, cx - bw / 2, bubbleY, bw, bh); + } else { + var level = (p.speakingLevel != null ? p.speakingLevel : 0.5); + var r0 = tileSize * (0.2 + level * 0.25); + ctx.strokeStyle = 'rgba(158, 206, 106, 0.85)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, headTop, r0, 0, Math.PI * 2); + ctx.stroke(); + } + } + const nameText = (p.nickname || id.slice(0, 6)); + const isHost = id === hostId; + const nameY = labelY + (tileSize * 0.125); + const voiceIconY = labelY - tileSize * 0.12; + if (p.voiceMicOn === false) { + const iconSize = Math.max(10, Math.round(tileSize * 0.35)); + ctx.font = iconSize + 'px NotoSansThai, Kanit, system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#f7768e'; + ctx.fillText('🔇', cx, voiceIconY); + ctx.textBaseline = 'alphabetic'; + } + if (p.ready && lobbyReadyIconImg && lobbyReadyIconImg.complete && lobbyReadyIconImg.naturalWidth) { + var nwI = lobbyReadyIconImg.naturalWidth; + var nhI = lobbyReadyIconImg.naturalHeight; + if (nwI > 0 && nhI > 0) { + var rih = 82; + var riw = 89; + var iconBottom = Math.round(nameY - nameFontSize * 1.0); + var iconTop = iconBottom - rih; + var iconLeft = Math.round(cx - riw / 2); + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(lobbyReadyIconImg, 0, 0, nwI, nhI, iconLeft, iconTop, riw, rih); + ctx.restore(); + } + } + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(2, Math.round(nameFontSize * 0.22)); + ctx.lineJoin = 'round'; + ctx.font = '400 ' + nameFontSize + 'px NotoSansThai, Kanit, sans-serif'; + ctx.textAlign = 'center'; + if (isHost && hostIconImg && hostIconImg.complete && hostIconImg.naturalWidth) { + const iconW = 53; + const iconH = 40; + const gap = Math.max(2, Math.round(tileSize * 0.06)); + const textW = ctx.measureText(nameText).width; + const totalW = iconW + gap + textW; + const startX = cx - totalW / 2; + ctx.drawImage(hostIconImg, 0, 0, hostIconImg.naturalWidth, hostIconImg.naturalHeight, startX, labelY - iconH / 2, iconW, iconH); + ctx.textAlign = 'left'; + ctx.strokeText(nameText, startX + iconW + gap, nameY); + ctx.fillText(nameText, startX + iconW + gap, nameY); + ctx.textAlign = 'center'; + } else { + ctx.strokeText(nameText, cx, nameY); + ctx.fillText(nameText, cx, nameY); + } + if (peerLockedOut) ctx.restore(); + }); + ctx.restore(); + syncQuizMapQuestionPanel(); + } + + function resizeAndDraw() { + if (!canvas) return; + const vp = window.visualViewport; + const w = Math.max(vp ? vp.width : 0, window.innerWidth || 0, document.documentElement.clientWidth || 0) || 800; + const h = Math.max(vp ? vp.height : 0, window.innerHeight || 0, document.documentElement.clientHeight || 0) || 600; + const pw = Math.floor(w); + const ph = Math.floor(h); + if (canvas.width !== pw || canvas.height !== ph) { + canvas.width = pw; + canvas.height = ph; + } + drawLobbyMap(); + } + + function redrawLobbyMap() { + if (canvas && mapData) drawLobbyMap(); + } + + function getFacingCellOffset(direction) { + const d = direction || 'down'; + if (d === 'up') return { dx: 0, dy: -1 }; + if (d === 'down') return { dx: 0, dy: 1 }; + if (d === 'left') return { dx: -1, dy: 0 }; + return { dx: 1, dy: 0 }; + } + + function cellIsInteractiveLobby(tx, ty) { + if (!mapData || !mapData.interactive) return false; + const row = mapData.interactive[ty]; + return !!(row && row[tx] === 1); + } + + /** ช่องที่กด F ได้: ช่องหน้าทิศทางก่อน แล้วค่อยช่องที่ยืนอยู่ */ + function getLobbyInteractTarget(me) { + if (!mapData || !me) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const px = Math.floor(me.x), py = Math.floor(me.y); + const { dx, dy } = getFacingCellOffset(me.direction); + const fx = px + dx, fy = py + dy; + if (fx >= 0 && fx < w && fy >= 0 && fy < h && cellIsInteractiveLobby(fx, fy)) return { x: fx, y: fy }; + if (cellIsInteractiveLobby(px, py)) return { x: px, y: py }; + return null; + } + + function appendLobbySystemChat(text) { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + div.className = 'chat-msg chat-msg-system'; + div.textContent = text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function quizCellOnLobby(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); + } + + function quizTilesFootprintLobby(px, py) { + const s = new Set(); + if (!mapData) return s; + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + 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 + cells - 1); + const maxTy = Math.min(h - 1, minTy + cells - 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; + } + + function quizAnswerTileForbiddenLobby(tx, ty) { + if (!quizPlayerLocal || quizPlayerLocal.eliminated) return false; + if (quizPlayerLocal.cannotTrue && quizCellOnLobby(mapData.quizTrueArea, tx, ty)) return true; + if (quizPlayerLocal.cannotFalse && quizCellOnLobby(mapData.quizFalseArea, tx, ty)) return true; + return false; + } + + function quizLockFootprintBlocksLobby(px, py) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + for (const k of quizTilesFootprintLobby(px, py)) { + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function quizLockWouldEnterForbiddenLobby(ox, oy, nx, ny) { + if (!mapData || mapData.gameType !== 'quiz' || !quizModeActive || !quizPlayerLocal || quizPlayerLocal.eliminated) return false; + const fromS = quizTilesFootprintLobby(ox, oy); + const toS = quizTilesFootprintLobby(nx, ny); + for (const k of toS) { + if (fromS.has(k)) continue; + const p = k.split(','); + const txi = +p[0], tyi = +p[1]; + if (quizAnswerTileForbiddenLobby(txi, tyi)) return true; + } + return false; + } + + function canWalkLobbyBot(x, y, fromX, fromY, botId) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, p] of peers) { + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + for (const [id, b] of lobbyBots) { + if (id === botId) continue; + if (Math.floor(b.x) === tx && Math.floor(b.y) === ty) return false; + } + } + return true; + } + + function canWalkLobby(x, y, fromX, fromY) { + if (!mapData) return false; + const w = mapData.width || 20, h = mapData.height || 15; + const tx = Math.floor(x), ty = Math.floor(y); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const ob = mapData.objects?.[ty]?.[tx] ?? 0; + if (ob === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [id, p] of peers) { + if (id === socket.id) continue; + if (Math.floor(p.x) === tx && Math.floor(p.y) === ty) return false; + } + } + if (mapData.gameType === 'quiz' && quizModeActive && quizPlayerLocal && !quizPlayerLocal.eliminated) { + const hasFrom = typeof fromX === 'number' && typeof fromY === 'number' && !Number.isNaN(fromX) && !Number.isNaN(fromY); + if (hasFrom) { + if (quizLockWouldEnterForbiddenLobby(fromX, fromY, x, y)) return false; + } else if (quizLockFootprintBlocksLobby(x, y)) { + return false; + } + } + return true; + } + + /** A* pathfinding on lobby grid. Returns array of { x, y } (cell centers). */ + function pathfindLobby(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 || !canWalkLobby(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 (!canWalkLobby(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 []; + } + + let lobbyPath = []; + + function lobbyMapRequiresStartGameArea() { + if (!mapData) return false; + var nameOk = String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + if (mapData.gameType !== 'lobby' && !nameOk) return false; + var a = mapData.startGameArea; + if (!a || !a.length) return false; + for (var y = 0; y < a.length; y++) { + var row = a[y]; + if (!row) continue; + for (var x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; + } + + /** ช่องกริดที่ตัวละครครอบคลุม (สูง×กว้าง จากแมป) — ใช้ตรวจพื้นที่ส้ม/เขียว ไม่ใช่แค่มุม anchor */ + function lobbyCharacterFootprintTiles(px, py) { + const tiles = new Set(); + if (!mapData) return tiles; + const w = mapData.width || 20; + const h = mapData.height || 15; + const cellsW = Math.max(1, Math.min(4, mapData.characterCellsW || mapData.characterCells || 1)); + const cellsH = Math.max(1, Math.min(4, mapData.characterCellsH || mapData.characterCells || 1)); + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const maxTx = Math.min(w - 1, minTx + cellsW - 1); + const maxTy = Math.min(h - 1, minTy + cellsH - 1); + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) tiles.add(tx + ',' + ty); + } + } + return tiles; + } + + function peerStandingInStartGameArea(me) { + if (!me || !mapData) return false; + const area = mapData.startGameArea; + if (!area || !area.length) return false; + for (const key of lobbyCharacterFootprintTiles(me.x, me.y)) { + const parts = key.split(','); + const tx = +parts[0]; + const ty = +parts[1]; + if (area[ty] && area[ty][tx] === 1) return true; + } + return false; + } + + function hostStandingInStartGameArea() { + return peerStandingInStartGameArea(peers.get(socket.id)); + } + + function updateHostStartGameButton() { + if (!btnStart) return; + if (hostId !== socket.id) return; + var hint = document.getElementById('host-start-area-hint'); + if (isCurrentRoomLobbyA()) { + btnStart.disabled = true; + btnStart.setAttribute('hidden', ''); + btnStart.setAttribute('aria-hidden', 'true'); + if (hint) { + hint.hidden = false; + var reqA = lobbyMapRequiresStartGameArea(); + var okA = !reqA || hostStandingInStartGameArea(); + if (reqA && !okA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ยืนพื้นส้ม แล้วกด F / ด เพื่อเลือกระดับและคดี'; + } else if (reqA) { + hint.textContent = 'LobbyA: กดพร้อมก่อน · ในพื้นส้ม กด F / ด เพื่อเลือกระดับและคดี'; + } else { + hint.textContent = 'LobbyA: กดพร้อมก่อน · กด F / ด เพื่อเลือกระดับและคดี'; + } + } + return; + } + btnStart.removeAttribute('hidden'); + btnStart.removeAttribute('aria-hidden'); + var req = lobbyMapRequiresStartGameArea(); + var ok = !req || hostStandingInStartGameArea(); + btnStart.disabled = !ok; + btnStart.title = req ? (ok ? 'เริ่มเกม' : 'ยืนในพื้นที่สีส้ม (เริ่มเกม) ก่อน') : 'เริ่มเกม'; + if (hint) { + if (req && !ok) { + hint.hidden = false; + hint.textContent = 'ยืนในพื้นที่สีส้มบนแผนที่ (จุดเริ่มเกม) แล้วค่อยกดเริ่ม'; + } else { + hint.hidden = true; + hint.textContent = ''; + } + } + } + + /** โถง LobbyA — host กดเริ่มแล้วให้เลือกระดับแล้วคดีก่อนเข้า play (ตรง Create Room → mlsbbxfe) */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + const LOBBY_A_MAP_ID = 'mlsbbxfe'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + if (String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase()) return true; + if (clientLobbyMapId === LOBBY_A_MAP_ID) return true; + if (String(mapData.id || '').trim() === LOBBY_A_MAP_ID) return true; + return false; + } + + /** Host บน LobbyA — หลังกดพร้อมแล้ว กด F ในพื้นที่ส้ม (หรือทั้งแมปถ้าไม่มีส้ม) เพื่อเลือกระดับ/คดี */ + function hostCanOpenLobbyAPreplayWithF() { + if (!isCurrentRoomLobbyA() || hostId !== socket.id) return false; + var req = lobbyMapRequiresStartGameArea(); + return !req || hostStandingInStartGameArea(); + } + + let preplaySelectedLevel = null; + let preplayOverlay = null; + let preplayStepLevel = null; + let preplayStepCase = null; + let preplayCaseDetailOverlay = null; + + function getPreplayEls() { + if (!preplayOverlay) { + preplayOverlay = document.getElementById('lobby-preplay-overlay'); + preplayStepLevel = document.getElementById('lobby-preplay-step-level'); + preplayStepCase = document.getElementById('lobby-preplay-step-case'); + preplayCaseDetailOverlay = document.getElementById('lobby-preplay-case-detail-overlay'); + } + return !!preplayOverlay; + } + + function closeLobbyPreplayWizard() { + getPreplayEls(); + if (!preplayOverlay) return; + preplayOverlay.classList.add('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'true'); + try { document.body.classList.remove('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + } + + function openLobbyPreplayWizard() { + if (!getPreplayEls()) return; + initLobbyPreplayWizard(); + preplaySelectedLevel = null; + delete preplayOverlay.dataset.preplayLevel; + preplayOverlay.querySelectorAll('.lobby-level-card.is-selected').forEach(function (el) { el.classList.remove('is-selected'); }); + preplayOverlay.classList.remove('is-hidden'); + preplayOverlay.setAttribute('aria-hidden', 'false'); + try { document.body.classList.add('room-lobby--preplay-open'); } catch (e) { /* ignore */ } + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function showPreplayCaseStep() { + if (preplayStepLevel) preplayStepLevel.classList.add('is-hidden'); + if (preplayStepCase) preplayStepCase.classList.remove('is-hidden'); + } + + function showPreplayLevelStep() { + if (preplayStepCase) preplayStepCase.classList.add('is-hidden'); + if (preplayStepLevel) preplayStepLevel.classList.remove('is-hidden'); + if (preplayCaseDetailOverlay) preplayCaseDetailOverlay.classList.add('is-hidden'); + } + + function initLobbyPreplayWizard() { + if (!getPreplayEls() || !preplayOverlay || preplayOverlay.dataset.bound === '1') return; + preplayOverlay.dataset.bound = '1'; + + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (btn) { + btn.addEventListener('click', function () { + preplayOverlay.querySelectorAll('.lobby-level-card').forEach(function (b) { b.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + preplaySelectedLevel = btn.getAttribute('data-level'); + if (preplaySelectedLevel) { + preplayOverlay.dataset.preplayLevel = preplaySelectedLevel; + showPreplayCaseStep(); + } + }); + }); + + var btnCloseLevel = document.getElementById('lobby-preplay-close-level'); + if (btnCloseLevel) btnCloseLevel.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnCloseCase = document.getElementById('lobby-preplay-close-case'); + if (btnCloseCase) btnCloseCase.addEventListener('click', function () { closeLobbyPreplayWizard(); }); + + var btnBackCase = document.getElementById('lobby-preplay-back-case'); + if (btnBackCase) btnBackCase.addEventListener('click', function () { showPreplayLevelStep(); }); + + var caseRow = preplayOverlay.querySelector('.lobby-preplay-case-row'); + var casePrev = document.getElementById('lobby-preplay-case-prev'); + var caseNext = document.getElementById('lobby-preplay-case-next'); + function scrollCaseRow(dir) { + if (!caseRow) return; + var firstCard = caseRow.querySelector('.lobby-case-card-wrap'); + var step = firstCard ? Math.max(120, Math.round(firstCard.getBoundingClientRect().width * 0.92)) : Math.max(140, Math.round(caseRow.clientWidth * 0.65)); + caseRow.scrollBy({ left: dir * step, behavior: 'smooth' }); + } + if (casePrev) casePrev.addEventListener('click', function () { scrollCaseRow(-1); }); + if (caseNext) caseNext.addEventListener('click', function () { scrollCaseRow(1); }); + + preplayOverlay.addEventListener('click', function (ev) { + var startBtn = ev.target.closest('.lobby-case-start'); + if (!startBtn || !preplayOverlay.contains(startBtn)) return; + var cid = (startBtn.getAttribute('data-case') || '').trim(); + var level = (preplaySelectedLevel && String(preplaySelectedLevel).trim()) + || (preplayOverlay.dataset.preplayLevel || '').trim(); + if (!cid) return; + if (!level) { + try { alert('กรุณาเลือกระดับความท้าทายก่อน (กลับไปขั้นตอนก่อนหน้าแล้วเลือกระดับ)'); } catch (e0) { /* ignore */ } + return; + } + var acked = false; + var t = setTimeout(function () { + if (!acked) { + try { alert('ไม่ได้รับตอบจากเซิร์ฟเวอร์ — ลองใหม่หรือรีเฟรชหน้า'); } catch (eT) { /* ignore */ } + } + }, 12000); + /* ไม่ส่ง mapId — กันประเภทเซิร์ฟรับแล้วไป play.html แทน LobbyB เมื่อเงื่อนไขย้ายแผนที่ไม่ผ่าน */ + socket.emit('start-game', { + lobbyLevel: level, + caseId: cid, + detectiveLobbyBStart: true + }, function (res) { + acked = true; + clearTimeout(t); + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e2) { /* ignore */ } + } + }); + }); + + document.querySelectorAll('.lobby-case-detail').forEach(function (btn) { + btn.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.remove('is-hidden'); + }); + }); + + var detailClose = document.getElementById('lobby-preplay-case-detail-close'); + if (detailClose) detailClose.addEventListener('click', function () { + var o = document.getElementById('lobby-preplay-case-detail-overlay'); + if (o) o.classList.add('is-hidden'); + }); + } + + const PATH_ARRIVE_THRESH = 0.15; + const LOBBY_BOT_WANDER_DIRS = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + + function stepLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData || lobbyBots.size === 0) return; + const w = mapData.width || 20, h = mapData.height || 15; + const now = Date.now(); + lobbyBots.forEach((b, id) => { + if (typeof b.botWanderDx !== 'number' || typeof b.botWanderDy !== 'number' || (b.botWanderDx === 0 && b.botWanderDy === 0)) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + if (typeof b.botWanderNextTurn !== 'number') b.botWanderNextTurn = now + 600; + if (now >= b.botWanderNextTurn) { + b.botWanderNextTurn = now + 650 + Math.floor(Math.random() * 2200); + if (Math.random() < 0.55) { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + } + } + const accX = b.botWanderDx; + const accY = b.botWanderDy; + if (Math.abs(accY) > Math.abs(accX)) b.direction = accY > 0 ? 'down' : 'up'; + else if (accX !== 0) b.direction = accX > 0 ? 'right' : 'left'; + const ox = b.x, oy = b.y; + const step = MOVE_SPEED; + const nx = b.x + accX * step; + const ny = b.y + accY * step; + if (canWalkLobbyBot(nx, ny, b.x, b.y, id)) { + b.x = nx; + b.y = ny; + } else if (canWalkLobbyBot(nx, b.y, b.x, b.y, id)) { + b.x = nx; + } else if (canWalkLobbyBot(b.x, ny, b.x, b.y, id)) { + b.y = ny; + } else { + const d = LOBBY_BOT_WANDER_DIRS[Math.floor(Math.random() * LOBBY_BOT_WANDER_DIRS.length)]; + b.botWanderDx = d[0]; + b.botWanderDy = d[1]; + b.botWanderNextTurn = now + 200 + Math.floor(Math.random() * 600); + } + b.x = Math.max(0, Math.min(w - 0.01, b.x)); + b.y = Math.max(0, Math.min(h - 0.01, b.y)); + b.isWalking = Math.abs(b.x - ox) > 1e-5 || Math.abs(b.y - oy) > 1e-5; + }); + } + + function lobbyTick() { + const me = peers.get(socket.id); + if (!mapData || !me) { requestAnimationFrame(lobbyTick); return; } + peers.forEach((p, id) => { + if (id !== socket.id && (p.tx != null || p.ty != null)) { + if (p.tx != null) p.x += (p.tx - p.x) * LERP; + if (p.ty != null) p.y += (p.ty - p.y) * LERP; + } + }); + if (!suspectPickOverlayOpen && lobbyBotSlotCount > 0) stepLobbyCaseBots(); + const w = mapData.width || 20, h = mapData.height || 15; + const preWalkX = me.x, preWalkY = me.y; + let accX = 0, accY = 0; + let usePath = false; + if (!suspectPickOverlayOpen) { + const keyPressed = !isChatFocused() && (keys['ArrowUp'] || keys['KeyW'] || keys['ArrowDown'] || keys['KeyS'] || keys['ArrowLeft'] || keys['KeyA'] || keys['ArrowRight'] || keys['KeyD']); + if (lobbyPath.length > 0 && keyPressed) lobbyPath = []; + if (lobbyPath.length > 0) { + const way = lobbyPath[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) { + lobbyPath.shift(); + while (lobbyPath.length > 0) { + const w2 = lobbyPath[0]; + const ux = w2.x - me.x, uy = w2.y - me.y; + if (Math.sqrt(ux * ux + uy * uy) > PATH_ARRIVE_THRESH) break; + lobbyPath.shift(); + } + if (lobbyPath.length === 0) { me.isWalking = false; redrawLobbyMap(); requestAnimationFrame(lobbyTick); return; } + usePath = true; + const next = lobbyPath[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 && !isChatFocused()) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else if (canWalkLobby(nx, me.y, me.x, me.y)) { + me.x = nx; + } else if (canWalkLobby(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)); + const now = Date.now(); + if (now - lastMoveSend > 80) { + lastMoveSend = now; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction || 'down' }); + } + } + } /* !suspectPickOverlayOpen */ + const movedThisTick = !suspectPickOverlayOpen && (Math.abs(me.x - preWalkX) > 1e-5 || Math.abs(me.y - preWalkY) > 1e-5); + me.isWalking = suspectPickOverlayOpen ? false : (!!(accX !== 0 || accY !== 0) || lobbyPath.length > 0 || movedThisTick); + updateHostStartGameButton(); + redrawLobbyMap(); + requestAnimationFrame(lobbyTick); + } + + function lobbyOccupantCount() { + return peers.size + lobbyBots.size; + } + + function lobbyOccupantSlots() { + return maxPlayers + lobbyBotSlotCount; + } + + function lobbySpawnTileWalkable(tx, ty) { + if (!mapData) return false; + const w = mapData.width || 20; + const h = mapData.height || 15; + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = mapData.objects && mapData.objects[ty]; + return !(row && row[tx] === 1); + } + + function lobbySpawnFootprintFits(anchorX, anchorY) { + if (!mapData) return false; + const cellsW = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsW) || Number(mapData.characterCells) || 1))); + const cellsH = Math.max(1, Math.min(4, Math.floor(Number(mapData.characterCellsH) || Number(mapData.characterCells) || 1))); + const w = mapData.width || 20; + const h = mapData.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 (!lobbySpawnTileWalkable(tx, ty)) return false; + } + } + return true; + } + + function parseLobbyPlayerSpawnsFromMapLobby(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; + } + + function pickRandomLobbySpawnFromMap() { + const fb = mapData.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fb.x)) ? Number(fb.x) : 1; + const fy = Number.isFinite(Number(fb.y)) ? Number(fb.y) : 1; + const grid = mapData.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = mapData.width || 20; + const h = mapData.height || 15; + const pool = []; + for (let yy = 0; yy < h; yy++) { + const row = grid[yy]; + if (!row) continue; + for (let xx = 0; xx < w; xx++) { + if (Number(row[xx]) === 1 && lobbySpawnFootprintFits(xx, yy)) pool.push({ x: xx, y: yy }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const pick = pool[Math.floor(Math.random() * pool.length)]; + return { x: pick.x, y: pick.y }; + } + + /** สอดคล้อง server pickSpawnForJoin — P1…P6 ตามลำดับเข้า */ + function pickLobbySpawnForJoin(joinOrderIndex) { + if (!mapData) return { x: 1, y: 1 }; + const mode = mapData.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomLobbySpawnFromMap(); + const j = Math.min(Math.max(0, ord), 5); + if (mode === 'fixed' && mapData.spawn) { + const fx = Number.isFinite(Number(mapData.spawn.x)) ? Math.floor(Number(mapData.spawn.x)) : 1; + const fy = Number.isFinite(Number(mapData.spawn.y)) ? Math.floor(Number(mapData.spawn.y)) : 1; + const w = mapData.width || 20; + const h = mapData.height || 15; + const x = Math.max(0, Math.min(w - 1, fx)); + const y = Math.max(0, Math.min(h - 1, fy)); + if (lobbySpawnFootprintFits(x, y)) return { x, y }; + return pickRandomLobbySpawnFromMap(); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMapLobby(mapData); + const pick = slots[j]; + if (pick && lobbySpawnFootprintFits(pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomLobbySpawnFromMap(); + } + return pickRandomLobbySpawnFromMap(); + } + + function pickLobbyBotSpawn(index) { + const sp = pickLobbySpawnForJoin(index); + return { x: sp.x, y: sp.y }; + } + + function syncLobbyCaseBots() { + if (!lobbyBotSlotCount || !mapData) { + lobbyBots.clear(); + return; + } + while (lobbyBots.size < lobbyBotSlotCount) { + const i = lobbyBots.size; + const id = LOBBY_BOT_PREFIX + i; + const sp = pickLobbyBotSpawn(peers.size + i); + const wanderDirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + const wd = wanderDirs[Math.floor(Math.random() * wanderDirs.length)]; + const bot = { + x: sp.x, + y: sp.y, + direction: 'down', + nickname: 'บอท ' + (i + 1), + ready: true, + characterId: getStoredCharacterId(), + isWalking: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), + }; + // สุ่มสีให้บอท (tint ผ่าน renderer Phase 3a) + var botColorIdx = 1 + Math.floor(Math.random() * 8); + var botSkinIdx = 1 + Math.floor(Math.random() * 3); + rlSampleSwatch('color', botColorIdx, function (rgb) { if (rgb) { bot.colorTheme = rgb; if (mapData && canvas) drawLobbyMap(); } }); + rlSampleSwatch('skin', botSkinIdx, function (rgb) { if (rgb) { bot.colorSkin = rgb; if (mapData && canvas) drawLobbyMap(); } }); + lobbyBots.set(id, bot); + } + while (lobbyBots.size > lobbyBotSlotCount) { + const keys = [...lobbyBots.keys()]; + lobbyBots.delete(keys[keys.length - 1]); + } + } + + function updatePlayersHud() { + const hudText = 'PLAYERS : ' + lobbyOccupantCount() + '/' + lobbyOccupantSlots(); + if (roomPlayersHud) roomPlayersHud.textContent = hudText; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = hudText; + } + + function renderPeers() { + if (!peersList) return; + peersList.innerHTML = ''; + const arr = [...peers.values()]; + arr.forEach(p => { + const div = document.createElement('div'); + div.className = 'room-lobby-peer'; + const name = p.nickname || p.id.slice(0, 8); + const readyText = p.ready ? ' ✓ พร้อม' : ' ยังไม่พร้อม'; + div.textContent = name + readyText; + peersList.appendChild(div); + }); + if (quizModeActive) renderQuizScoreboard(lastQuizScores); + } + + function getStoredCharacterId() { + try { + const v = (localStorage.getItem('gameCharacterId') || '').trim(); + if (v && v !== 'Chatest') return v; // 'Chatest' = legacy placeholder ไม่มี sprite จริง + return rlDefaultCharId || ''; // ไม่มีตัวที่เลือก → ใช้ตัว default ที่มี sprite จริง + } catch (e) { + return rlDefaultCharId || ''; + } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + if (!id) { + img.removeAttribute('src'); + img.alt = (profileDisplayName || nick || 'ผู้เล่น'); + return; + } + if (myTintTheme || myTintSkin) { updateRoomProfileAvatarTinted(); return; } // ใช้รูป tint สี กันถูกเขียนทับเป็นรูป baked + try { + const du = localStorage.getItem(LOBBY_IDLE_DOWN_LS + id); + if (du && typeof du === 'string' && du.indexOf('data:image/') === 0) { + img.onload = null; + img.onerror = null; + img.src = du; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + return; + } + } catch (e) { /* ignore */ } + const urls = characterSpriteUrlCandidates(id, 'down'); + let uidx = 0; + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + img.onerror = function () { + uidx += 1; + if (uidx >= urls.length) { + img.onerror = null; + img.removeAttribute('src'); + return; + } + img.src = urls[uidx]; + }; + img.onload = function () { + img.onerror = null; + }; + img.src = urls[0]; + } + + function loadProfileDisplayName() { + try { + const saved = (localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || '').trim(); + if (saved) profileDisplayName = saved; + } catch (e) { /* ignore */ } + } + + function saveProfileDisplayName(nextName) { + const safeName = String(nextName || '').trim(); + if (!safeName) return; + profileDisplayName = safeName; + try { localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, safeName); } catch (e) { /* ignore */ } + } + + function getProfileDisplayName() { + const me = peers.get(socket.id); + const serverName = me && typeof me.nickname === 'string' ? me.nickname.trim() : ''; + if (serverName) return serverName; + return profileDisplayName || nick || 'PLAYER'; + } + + loadProfileDisplayName(); + + function padAgentId(n) { + let s = String(n || ''); + while (s.length < 6) s = '0' + s; + return s; + } + + function ensureAgentDisplayId() { + const key = 'agentDisplayId'; + try { + let v = (localStorage.getItem(key) || '').trim(); + if (!/^\d{6}$/.test(v)) { + v = padAgentId(100000 + Math.floor(Math.random() * 899999)); + localStorage.setItem(key, v); + } + return v; + } catch (e) { + return padAgentId(100000 + Math.floor(Math.random() * 899999)); + } + } + + function ensurePlayerKey() { + try { + let k = (localStorage.getItem(PLAYER_KEY) || '').trim(); + if (!k || k.length < 8) { + k = 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + localStorage.setItem(PLAYER_KEY, k); + } + return k; + } catch (e) { + return 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 14); + } + } + + function getStoredCoins() { + try { + const raw = localStorage.getItem('jdCoins'); + const n = Math.max(0, parseInt(raw, 10) || 0); + return n; + } catch (e) { + return 0; + } + } + + function syncLobbyAvatarFromStorage() { + updateLobbyProfileAvatar(); + if (typeof redrawLobbyMap === 'function') redrawLobbyMap(); + } + + class RoomLobbyProfileOverlay { + constructor() { + this.overlay = document.getElementById('room-lobby-profile-overlay'); + this.backdrop = document.getElementById('room-lobby-profile-backdrop'); + this.closeBtn = document.getElementById('room-lobby-profile-close'); + this.dialogEl = document.querySelector('.room-lobby-profile-dialog'); + this.innerFrameEl = document.querySelector('.room-lobby-profile-inner-frame'); + this.coinsRowEl = document.querySelector('.room-lobby-profile-coins'); + this.coinsLabelEl = document.querySelector('.room-lobby-profile-coins-label'); + this.coinsIconEl = document.querySelector('.room-lobby-profile-coins-icon'); + this.avatarEl = document.getElementById('room-lobby-profile-overlay-avatar'); + this.nameEl = document.getElementById('room-lobby-profile-overlay-name'); + this.agentEl = document.getElementById('room-lobby-profile-overlay-agent'); + this.coinsEl = document.getElementById('room-lobby-profile-coins-val'); + this.musicBtn = document.getElementById('room-lobby-profile-music'); + this.sfxBtn = document.getElementById('room-lobby-profile-sfx'); + this.archivGroupBtns = Array.from(document.querySelectorAll('.room-lobby-profile-archiv-group-btn')); + this.editBtn = document.getElementById('room-lobby-profile-edit-btn'); + this.activeArchivGroup = 1; + this._boundSyncProfileFrameScale = () => this._syncProfileFrameScale(); + this._profileFrameScaleObserver = null; + this._bindEvents(); + this._initProfileFrameScaleObserver(); + this._syncProfileFrameScale(); + this._applySwitchVisual(this.musicBtn, true); + this._applySwitchVisual(this.sfxBtn, false); + this._setArchivGroup(1); + this._syncCoinsFromServer(); + } + + _bindEvents() { + this.backdrop?.addEventListener('click', () => this.close()); + this.closeBtn?.addEventListener('click', () => this.close()); + this.musicBtn?.addEventListener('click', () => this._toggleChip(this.musicBtn)); + this.sfxBtn?.addEventListener('click', () => this._toggleChip(this.sfxBtn)); + this.archivGroupBtns.forEach((btn) => { + btn.addEventListener('click', () => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + this._setArchivGroup(Number.isFinite(raw) ? raw : 1); + }); + }); + this.editBtn?.addEventListener('click', () => this._editDisplayName()); + window.addEventListener('resize', this._boundSyncProfileFrameScale); + } + + _initProfileFrameScaleObserver() { + if (!this.innerFrameEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._profileFrameScaleObserver = new ResizeObserver(() => this._syncProfileFrameScale()); + this._profileFrameScaleObserver.observe(this.innerFrameEl); + } + + _syncProfileFrameScale() { + let dialogScale = 1; + if (this.dialogEl) { + const dialogWidth = this.dialogEl.clientWidth || 0; + if (dialogWidth > 0) { + const rawDialogScale = dialogWidth / 1701; + dialogScale = Math.max(0.35, Math.min(1, rawDialogScale)); + this.dialogEl.style.setProperty('--rp-scale', String(dialogScale.toFixed(4))); + } + } + if (!this.innerFrameEl) return; + this.innerFrameEl.style.setProperty('--pf-scale', String(dialogScale.toFixed(4))); + } + + _toggleChip(btn) { + if (!btn) return; + const current = String(btn.getAttribute('data-on') || '').toLowerCase() === 'true'; + this._applySwitchVisual(btn, !current); + } + + _applySwitchVisual(btn, isOn) { + if (!btn) return; + const on = !!isOn; + btn.setAttribute('data-on', on ? 'true' : 'false'); + const img = btn.querySelector('img'); + if (!img) return; + const nextSrc = on ? 'img/03-6-Profile/btn-on.png' : 'img/03-6-Profile/btn-off.png'; + img.src = nextSrc; + img.alt = on ? 'เปิด' : 'ปิด'; + } + + _setArchivGroup(groupIndex) { + let idx = parseInt(groupIndex, 10); + if (!Number.isFinite(idx) || idx < 1 || idx > 5) idx = 1; + this.activeArchivGroup = idx; + this.archivGroupBtns.forEach((btn) => { + const raw = parseInt(btn.getAttribute('data-group'), 10); + const id = Number.isFinite(raw) ? raw : 1; + const active = id === idx; + btn.classList.toggle('is-active', active); + const img = btn.querySelector('img'); + if (!img) return; + img.src = active + ? 'img/03-6-Profile/archiv-group-0' + id + '-a.png' + : 'img/03-6-Profile/archiv-group-0' + id + '.png'; + }); + } + + setProfileData(data) { + const payload = data || {}; + const avatarSrc = payload.avatarSrc || document.getElementById('room-lobby-profile-avatar')?.src || ''; + if (this.avatarEl && avatarSrc) this.avatarEl.src = avatarSrc; + if (this.nameEl) this.nameEl.textContent = payload.displayName || getProfileDisplayName(); + const agentId = payload.agentIdLabel || ('AGENT ID : ' + ensureAgentDisplayId()); + if (this.agentEl) this.agentEl.textContent = agentId; + const coinsVal = payload.coins != null ? payload.coins : getStoredCoins(); + if (this.coinsEl) this.coinsEl.textContent = String(Math.max(0, parseInt(coinsVal, 10) || 0)); + } + + _syncCoinsFromServer() { + const key = ensurePlayerKey(); + const url = (typeof appPath === 'function' ? appPath('/Admin/api/player-coins.php') : '/Admin/api/player-coins.php') + + '?playerKey=' + encodeURIComponent(key); + fetch(url, { credentials: 'omit' }) + .then((r) => r.json()) + .then((d) => { + if (!d || !d.ok) return; + const coins = Math.max(0, parseInt(d.coins, 10) || 0); + try { localStorage.setItem('jdCoins', String(coins)); } catch (e) { /* ignore */ } + if (this.coinsEl) this.coinsEl.textContent = String(coins); + }) + .catch(() => { /* ignore */ }); + } + + _editDisplayName() { + const currentName = this.nameEl ? String(this.nameEl.textContent || '').trim() : getProfileDisplayName(); + const draft = window.prompt('แก้ไขชื่อผู้เล่น', currentName || ''); + if (draft == null) return; + const nextName = String(draft).trim(); + if (!nextName) { + alert('กรุณากรอกชื่อผู้เล่น'); + return; + } + saveProfileDisplayName(nextName); + this.setProfileData({ displayName: nextName }); + updateLobbyProfileAvatar(); + } + + open(data) { + if (!this.overlay) return; + this.setProfileData(data); + this._syncCoinsFromServer(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncProfileFrameScale(); + requestAnimationFrame(() => this._syncProfileFrameScale()); + requestAnimationFrame(() => requestAnimationFrame(() => this._syncProfileFrameScale())); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + + toggle(force, data) { + if (!this.overlay) return; + const shouldOpen = typeof force === 'boolean' ? force : this.overlay.classList.contains('is-hidden'); + if (shouldOpen) this.open(data); + else this.close(); + } + } + + const roomLobbyProfileOverlay = new RoomLobbyProfileOverlay(); + window.RoomLobbyProfileOverlay = RoomLobbyProfileOverlay; + window.roomLobbyProfileOverlay = roomLobbyProfileOverlay; + + class HostConsoleOverlay { + constructor() { + this.overlay = document.getElementById('host-console-overlay'); + this.backdrop = document.getElementById('host-console-backdrop'); + this.dialogEl = document.querySelector('.room-lobby-host-console-dialog'); + this.closeBtn = document.getElementById('host-console-close'); + this.confirmBtn = document.getElementById('host-console-confirm'); + this.decBtn = document.getElementById('host-console-max-dec'); + this.incBtn = document.getElementById('host-console-max-inc'); + this.maxValueEl = document.getElementById('host-console-max-value'); + this.summaryEl = document.getElementById('host-console-summary'); + this.membersListEl = document.getElementById('host-console-members-list'); + this.pendingMaxPlayers = maxPlayers; + this._boundSyncScale = () => this._syncScale(); + this._scaleObserver = null; + this._bindEvents(); + this._initScaleObserver(); + this._syncScale(); + } + + _bindEvents() { + // Close only via the X hit area button (and Esc key handler). + this.closeBtn?.addEventListener('click', () => this.close()); + this.decBtn?.addEventListener('click', () => this._changeMax(-1)); + this.incBtn?.addEventListener('click', () => this._changeMax(1)); + this.confirmBtn?.addEventListener('click', () => this._confirm()); + window.addEventListener('resize', this._boundSyncScale); + } + + _initScaleObserver() { + if (!this.dialogEl) return; + if (typeof ResizeObserver === 'undefined') return; + this._scaleObserver = new ResizeObserver(() => this._syncScale()); + this._scaleObserver.observe(this.dialogEl); + } + + _syncScale() { + if (!this.dialogEl) return; + const w = this.dialogEl.clientWidth || 0; + const h = this.dialogEl.clientHeight || 0; + if (!w || !h) return; + const scaleW = w / 990; + const scaleH = h / 881; + const scale = Math.max(0.5, Math.min(1.08, Math.min(scaleW, scaleH))); + this.dialogEl.style.setProperty('--hc-scale', String(scale.toFixed(4))); + } + + _isHost() { + return hostId === socket.id; + } + + _getPlayersCount() { + return Math.max(0, peers.size); + } + + _changeMax(delta) { + if (!this._isHost()) return; + const currentPlayers = this._getPlayersCount(); + const minAllowed = Math.max(2, currentPlayers); + const maxAllowed = 10; + const next = this.pendingMaxPlayers + delta; + this.pendingMaxPlayers = Math.max(minAllowed, Math.min(maxAllowed, next)); + this._render(); + } + + _kickMember(row) { + if (!this._isHost()) return; + if (!row || !row.id || row.isHost) return; + socket.emit('host-console-kick-peer', { targetId: row.id }, (res) => { + if (!res || !res.ok) { + var msg = (res && res.error) ? res.error : 'ลบสมาชิกไม่สำเร็จ'; + try { alert(msg); } catch (e) { /* ignore */ } + return; + } + }); + } + + _renderMembers() { + if (!this.membersListEl) return; + this.membersListEl.textContent = ''; + const rows = [...peers.entries()].map(([id, p]) => ({ + id, + isHost: id === hostId, + name: (p && p.nickname) ? String(p.nickname).trim() : id.slice(0, 8) + })); + rows.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return a.name.localeCompare(b.name, 'th'); + }); + + const frag = document.createDocumentFragment(); + rows.forEach((row) => { + const li = document.createElement('li'); + li.className = 'room-lobby-host-console-member-item' + (row.isHost ? ' is-host' : ''); + + const name = document.createElement('span'); + name.className = 'room-lobby-host-console-member-name'; + name.textContent = row.isHost ? (row.name + ' (Host)') : row.name; + li.appendChild(name); + + if (!row.isHost) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'room-lobby-host-console-delete-btn'; + delBtn.disabled = !this._isHost(); + delBtn.setAttribute('aria-label', 'ลบสมาชิก ' + row.name); + const delImg = document.createElement('img'); + delImg.src = 'img/03-4-Host-Console/host-console-delete.png'; + delImg.alt = ''; + delImg.decoding = 'async'; + delBtn.appendChild(delImg); + delBtn.addEventListener('click', () => this._kickMember(row)); + li.appendChild(delBtn); + } + frag.appendChild(li); + }); + this.membersListEl.appendChild(frag); + } + + _render() { + const players = this._getPlayersCount(); + const bots = Math.max(0, this.pendingMaxPlayers - players); + if (this.maxValueEl) this.maxValueEl.textContent = String(this.pendingMaxPlayers); + if (this.summaryEl) this.summaryEl.textContent = 'สรุปจำนวน : ' + players + ' ผู้เล่น + ' + bots + ' Bot'; + this._renderMembers(); + + const canEdit = this._isHost(); + if (this.decBtn) this.decBtn.disabled = !canEdit; + if (this.incBtn) this.incBtn.disabled = !canEdit; + if (this.confirmBtn) this.confirmBtn.disabled = !canEdit; + } + + _confirm() { + if (!this._isHost()) return; + maxPlayers = this.pendingMaxPlayers; + updatePlayersHud(); + this.close(); + } + + open() { + if (!this.overlay) return; + this.pendingMaxPlayers = Math.max(2, maxPlayers || 10); + this._render(); + this.overlay.classList.remove('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'false'); + this._syncScale(); + requestAnimationFrame(() => this._syncScale()); + this.closeBtn?.focus(); + } + + close() { + if (!this.overlay) return; + this.overlay.classList.add('is-hidden'); + this.overlay.setAttribute('aria-hidden', 'true'); + } + } + + const hostConsoleOverlay = new HostConsoleOverlay(); + window.hostConsoleOverlay = hostConsoleOverlay; + function refreshHostConsoleOverlayIfOpen() { + if (!hostConsoleOverlay || !hostConsoleOverlay.overlay) return; + if (hostConsoleOverlay.overlay.classList.contains('is-hidden')) return; + hostConsoleOverlay._render(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getStoredCharacterId() }, (res) => { + if (!res || !res.ok) { + var joinErr = (res && res.error) || 'เข้าไม่ได้'; + if (/เริ่มคดี|ไม่รับผู้เล่น/.test(joinErr)) { + alert(joinErr + '\n\nถ้าคุณเคยอยู่ในห้องนี้แล้ว: ให้ใช้ nick ในลิงก์ให้ตรงกับชื่อที่ใช้ตอนเข้าห้องครั้งแรก แล้วรีเฟรชหน้า'); + } else { + alert(joinErr); + } + location.href = BASE + '/lobby.html'; + return; + } + mapData = res.mapData; + roomJoinReady = true; + maybeHideRoomLoading(); + markRoomCzInteractiveCell(); + if (ROOM_CZ_SPOT) appendLobbySystemChat('— เดินไปช่องเขียว แล้วกด F เพื่อเปิดห้องแต่งตัว'); + clientLobbyMapId = res.mapId != null ? res.mapId : null; + hostId = res.hostId || null; + spaceName = (res.spaceName || '').trim() || spaceId; + (res.peers || []).forEach(p => { peers.set(p.id, normalizeLobbyPeerFromServer(p, mapData)); }); + + if (mapData && mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + mapBackgroundImg.onload = () => { resizeAndDraw(); }; + } + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + + maxPlayers = res.maxPlayers != null ? res.maxPlayers : 10; + lobbyBotSlotCount = res.botSlotCount != null ? Math.max(0, parseInt(res.botSlotCount, 10) || 0) : 0; + if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { + lobbyBotSlotCount = 6 - maxPlayers; + } + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + updatePlayersHud(); + syncLobbyBUiChrome(); + + if (wasPageReload() && (hostId === socket.id || peers.size <= 1)) { + window.location.replace(CREATE_ROOM_URL); + return; + } + + renderPeers(); + updateLobbyProfileAvatar(); + var meAfterJoin = peers.get(socket.id); + if (readyCheck && meAfterJoin) { + readyCheck.checked = !!meAfterJoin.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + + if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); + if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID || res.suspectPhaseActive) { + try { + var uLobbyB = new URL(window.location.href); + uLobbyB.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', uLobbyB.pathname + uLobbyB.search); + } catch (eMap) { /* ignore */ } + } + + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + + resizeAndDraw(); + updateHostStartGameButton(); + syncLobbyBUiChrome(); + lobbyTick(); + + setTimeout(() => { + unlockAudio(); + const hearBtn = document.getElementById('btn-hear'); + if (hearBtn) { hearBtn.textContent = '✓ เปิดรับเสียงแล้ว'; hearBtn.disabled = true; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return; + navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { + stream.getTracks().forEach(t => t.stop()); + const permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + if (typeof populateVoiceDevices === 'function') populateVoiceDevices(); + }).catch(() => {}); + }, 600); + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('user-left', (data) => { + if (typeof closePeer === 'function') closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + }); + + socket.on('host-console-kicked', (data) => { + var msg = (data && data.message) ? String(data.message) : 'คุณถูก Host นำออกจากห้อง'; + try { alert(msg); } catch (e) { /* ignore */ } + window.location.href = CREATE_ROOM_URL; + }); + + socket.on('peer-ready', (data) => { + const p = peers.get(data.id); + if (p) { p.ready = data.ready; renderPeers(); redrawLobbyMap(); } + if (data && data.id === socket.id && readyCheck) { + readyCheck.checked = !!data.ready; + updateReadyLabelVisual(); + } + ensureReadyControlEnabled(); + }); + + socket.on('user-move', (data) => { + const p = peers.get(data.id); + if (p) { + if (data.id === socket.id) { + if (data.x != null) { + const x = Number(data.x); + if (Number.isFinite(x)) { p.x = x; p.tx = x; } + } + if (data.y != null) { + const y = Number(data.y); + if (Number.isFinite(y)) { p.y = y; p.ty = y; } + } + } else { + if (data.x != null) p.tx = data.x; + if (data.y != null) p.ty = data.y; + } + if (data.direction) p.direction = data.direction; + if (data.characterId != null) p.characterId = data.characterId; + redrawLobbyMap(); + } + }); + + function hideQuizScoreboardAndFeedback() { + var fb = document.getElementById('quiz-feedback-banner'); + if (fb) { fb.classList.add('is-hidden'); fb.textContent = ''; } + } + + function renderQuizScoreboard(scores) { + var ov = document.getElementById('quiz-game-overlay'); + var ul = document.getElementById('quiz-scoreboard-list'); + if (!ul) return; + if (!quizModeActive) { + if (ov) ov.classList.add('is-hidden'); + return; + } + if (ov) ov.classList.remove('is-hidden'); + var merged = scores && typeof scores === 'object' ? Object.assign({}, scores) : {}; + peers.forEach(function (_, id) { + if (merged[id] == null) merged[id] = 0; + }); + ul.textContent = ''; + var rows = []; + peers.forEach(function (p, id) { + rows.push({ + id: id, + nick: (p && p.nickname) ? String(p.nickname) : id, + sc: merged[id] != null ? merged[id] : 0, + characterId: p && p.characterId ? String(p.characterId) : '', + }); + }); + rows.sort(function (a, b) { + if (b.sc !== a.sc) return b.sc - a.sc; + return a.nick.localeCompare(b.nick, 'th'); + }); + rows.forEach(function (row) { + var li = document.createElement('li'); + if (row.id === socket.id) li.className = 'quiz-scoreboard-me'; + var av = document.createElement(row.characterId ? 'img' : 'div'); + av.className = 'quiz-sb-avatar'; + if (row.characterId) { + av.alt = ''; + var duSb = ''; + try { + duSb = localStorage.getItem(LOBBY_IDLE_DOWN_LS + row.characterId) || ''; + } catch (eDu) { duSb = ''; } + if (duSb && duSb.indexOf('data:image/') === 0) { + av.src = duSb; + } else { + var urlsSb = characterSpriteUrlCandidates(row.characterId, 'down'); + var qi = 0; + av.onerror = function () { + qi += 1; + if (qi >= urlsSb.length) { + av.onerror = null; + av.removeAttribute('src'); + return; + } + av.src = urlsSb[qi]; + }; + av.src = urlsSb[0]; + } + } + var meta = document.createElement('div'); + meta.className = 'quiz-sb-meta'; + var spName = document.createElement('span'); + spName.className = 'quiz-scoreboard-name'; + spName.textContent = row.nick; + var spVal = document.createElement('span'); + spVal.className = 'quiz-scoreboard-val'; + spVal.textContent = String(row.sc); + meta.appendChild(spName); + meta.appendChild(spVal); + li.appendChild(av); + li.appendChild(meta); + ul.appendChild(li); + }); + } + + function initQuizScoreboardZeros() { + if (!quizModeActive) return; + lastQuizScores = {}; + peers.forEach(function (_, id) { lastQuizScores[id] = 0; }); + renderQuizScoreboard(lastQuizScores); + } + + function showQuizRoundFeedback(r) { + var el = document.getElementById('quiz-feedback-banner'); + if (!el || !r || !r.results) return; + var mine = null; + for (var i = 0; i < r.results.length; i++) { + if (r.results[i].id === socket.id) { mine = r.results[i]; break; } + } + if (!mine) return; + el.classList.remove('is-hidden'); + if (mine.right) { + el.className = 'quiz-feedback-banner quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม'; + } else { + el.className = 'quiz-feedback-banner quiz-feedback-bad'; + if (mine.choice == null) { + el.textContent = 'คุณไม่ได้ยืนในโซนตอบ (จริง/เท็จ) — นับเป็นผิด'; + } else { + el.textContent = 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีกในเกมนี้'; + } + } + if (typeof window.__quizFeedbackHideT === 'number') clearTimeout(window.__quizFeedbackHideT); + window.__quizFeedbackHideT = setTimeout(function () { + el.classList.add('is-hidden'); + }, 4200); + } + + function showQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + } + function hideQuizOverlay() { + var ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (quizTimerInterval) { clearInterval(quizTimerInterval); quizTimerInterval = null; } + var panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + hideQuizScoreboardAndFeedback(); + } + function updateQuizTimerDisplay() { + var el = document.getElementById('quiz-game-timer'); + if (!el || !quizPhaseEndsAt) return; + var s = Math.max(0, Math.ceil((quizPhaseEndsAt - Date.now()) / 1000)); + el.textContent = String(s); + } + + socket.on('quiz-phase', function (p) { + if (!p) return; + if (p.text) lastQuizQuestionText = p.text; + quizPhaseLocal = p.phase; + quizPhaseEndsAt = p.endsAt || 0; + lastQuizQIdx = typeof p.questionIndex === 'number' ? p.questionIndex : 0; + lastQuizQTotal = typeof p.questionTotal === 'number' ? p.questionTotal : 0; + var phaseEl = document.getElementById('quiz-game-phase-label'); + var qEl = document.getElementById('quiz-game-question'); + var numEl = document.getElementById('quiz-hud-quiz-num'); + if (numEl) { + numEl.textContent = '- Quiz ' + (p.questionIndex || '—') + ' / ' + (p.questionTotal || '—') + ' -'; + } + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? 'อ่านคำถาม' + : 'เดินเข้าโซน SAFE (จริง) หรือ SCAM (เท็จ)'; + } + if (qEl) qEl.textContent = lastQuizQuestionText || ''; + showQuizOverlay(); + if (quizTimerInterval) clearInterval(quizTimerInterval); + quizTimerInterval = setInterval(updateQuizTimerDisplay, 200); + updateQuizTimerDisplay(); + if (Object.keys(lastQuizScores).length) renderQuizScoreboard(lastQuizScores); + redrawLobbyMap(); + }); + + socket.on('quiz-player-state', function (st) { + if (!st) return; + quizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (quizPlayerLocal.score || 0), + }; + redrawLobbyMap(); + }); + + socket.on('quiz-result', function (r) { + if (!r) return; + var correct = r.correctTrue ? 'เฉลยข้อนี้: ถูก = จริง' : 'เฉลยข้อนี้: ถูก = เท็จ'; + var line = '— ข้อ ' + (r.questionIndex || '') + ' ' + correct; + if (r.allWrong) line += ' · ทุกคนผิด — จบเกม'; + appendLobbySystemChat(line); + if (r.results) { + r.results.forEach(function (row) { + if (!row.right) quizPeersLocked[row.id] = true; + }); + } + if (r.scores) { + lastQuizScores = r.scores; + renderQuizScoreboard(lastQuizScores); + } + showQuizRoundFeedback(r); + if (r.results) { + r.results.forEach(function (row) { + if (row.id === socket.id) { + var det = row.right ? 'ถูก' : 'ผิด'; + if (!row.right && row.choice == null) det += ' (ไม่ได้ยืนในโซน)'; + appendLobbySystemChat('— คุณตอบ' + det + ' · คะแนนรวม ' + (typeof row.score === 'number' ? row.score : 0)); + } + }); + } + redrawLobbyMap(); + }); + + socket.on('quiz-ended', function (d) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + appendLobbySystemChat('— ' + (d && d.message ? d.message : 'จบเกมตอบคำถาม')); + if (d && d.returnToLobbyB) { + serverSuspectPhaseActive = true; + updateSuspectFloatingOpenBtn(); + } + redrawLobbyMap(); + }); + + socket.on('detective-minigame-ended', function (data) { + applyDetectiveReturnToLobbyB(data || {}); + }); + + socket.on('lobby-interact', (data) => { + if (!data || data.x == null || data.y == null) return; + lobbyInteractPulse = { x: data.x, y: data.y, until: Date.now() + 700 }; + const name = (data.nickname || 'ผู้เล่น').trim(); + appendLobbySystemChat('★ ' + name + ' โต้ตอบกับจุดในห้อง'); + redrawLobbyMap(); + }); + + socket.on('chat', (data) => { + const el = document.getElementById('chat-messages'); + if (!el) return; + const div = document.createElement('div'); + const isMe = data.id === socket.id; + div.className = 'chat-msg ' + (isMe ? 'chat-msg-mine' : 'chat-msg-other'); + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + el.appendChild(div); + el.scrollTop = 1e9; + }); + + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + if (chatForm && chatInput) { + chatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (chatInput.value || '').trim(); + if (text) { socket.emit('chat', text); chatInput.value = ''; } + }); + } + const chatCloseBtn = document.getElementById('chat-close-btn'); + const chatToggleImg = document.getElementById('chat-toggle-img'); + if (chatCloseBtn && chatToggleImg) { + function updateChatToggleIcon() { + const panel = document.querySelector('.room-lobby-chat-peers'); + const collapsed = panel && panel.classList.contains('chat-panel-collapsed'); + chatToggleImg.src = collapsed ? SERVER + '/img/btn-chat.png' : SERVER + '/img/chat-close-btn.png'; + chatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + chatCloseBtn.setAttribute('aria-label', collapsed ? 'เปิดแชท' : 'ปิดแชท'); + } + chatCloseBtn.addEventListener('click', function () { + const panel = document.querySelector('.room-lobby-chat-peers'); + if (panel) { + panel.classList.toggle('chat-panel-collapsed'); + const collapsed = panel.classList.contains('chat-panel-collapsed'); + panel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateChatToggleIcon(); + } + }); + const peerPanelInit = document.querySelector('.room-lobby-chat-peers'); + if (peerPanelInit) { + peerPanelInit.setAttribute('aria-expanded', peerPanelInit.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + } + updateChatToggleIcon(); + } + + const aiChatCloseBtn = document.getElementById('ai-chat-close-btn'); + const aiChatPanel = document.getElementById('ai-chat-panel'); + const aiChatToggleImg = document.getElementById('ai-chat-toggle-img'); + if (aiChatCloseBtn && aiChatPanel) { + function updateAiChatToggleIcon() { + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + if (aiChatToggleImg) aiChatToggleImg.src = collapsed ? MAIN_LOBBY_AI_BTN_ICON : SERVER + '/img/chat-close-btn.png'; + if (aiChatToggleImg) aiChatToggleImg.alt = collapsed ? 'เทพความรู้' : 'ปิด'; + aiChatCloseBtn.setAttribute('title', collapsed ? 'เปิดแชท AI' : 'ปิดแชท AI'); + aiChatCloseBtn.setAttribute('aria-label', aiChatCloseBtn.getAttribute('title')); + } + aiChatCloseBtn.addEventListener('click', function () { + aiChatPanel.classList.toggle('chat-panel-collapsed'); + const collapsed = aiChatPanel.classList.contains('chat-panel-collapsed'); + aiChatPanel.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + updateAiChatToggleIcon(); + }); + aiChatPanel.setAttribute('aria-expanded', aiChatPanel.classList.contains('chat-panel-collapsed') ? 'false' : 'true'); + updateAiChatToggleIcon(); + } + + const aiChatForm = document.getElementById('ai-chat-form'); + const aiChatInput = document.getElementById('ai-chat-input'); + const aiChatSendBtn = aiChatForm ? aiChatForm.querySelector('button[type="submit"]') : null; + var aiChatLoadingEl = null; + + function appendAiMessage(text, isAi) { + var el = document.getElementById('ai-chat-messages'); + if (!el) return; + if (isAi) removeAiChatLoading(); + var div = document.createElement('div'); + div.className = 'chat-msg ' + (isAi ? 'chat-msg-ai' : 'chat-msg-mine'); + div.textContent = (isAi ? 'AI: ' : '') + text; + el.appendChild(div); + el.scrollTop = 1e9; + } + + function showAiChatLoading() { + var el = document.getElementById('ai-chat-messages'); + if (!el || aiChatLoadingEl) return; + aiChatLoadingEl = document.createElement('div'); + aiChatLoadingEl.className = 'chat-msg chat-msg-ai ai-chat-typing'; + aiChatLoadingEl.setAttribute('aria-label', 'กำลังพิมพ์'); + aiChatLoadingEl.innerHTML = ''; + el.appendChild(aiChatLoadingEl); + el.scrollTop = 1e9; + } + + function removeAiChatLoading() { + if (aiChatLoadingEl && aiChatLoadingEl.parentNode) { + aiChatLoadingEl.parentNode.removeChild(aiChatLoadingEl); + aiChatLoadingEl = null; + } + } + + function setAiChatWaiting(waiting) { + if (aiChatInput) aiChatInput.disabled = waiting; + if (aiChatSendBtn) aiChatSendBtn.disabled = waiting; + if (waiting) showAiChatLoading(); else removeAiChatLoading(); + } + + if (aiChatForm && aiChatInput) { + aiChatForm.addEventListener('submit', function (e) { + e.preventDefault(); + var text = (aiChatInput.value || '').trim(); + if (!text) return; + appendAiMessage(text, false); + setAiChatWaiting(true); + aiChatInput.value = ''; + fetch(SERVER + '/api/ai-chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, sessionId: socket.id }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.response != null) appendAiMessage(data.response, true); + else if (data.error) appendAiMessage('[ข้อผิดพลาด: ' + data.error + ']', true); + }) + .catch(function () { appendAiMessage('[ส่งไม่สำเร็จ]', true); }) + .finally(function () { setAiChatWaiting(false); }); + }); + } + + let localStream = null; + let voiceActivityInterval = null; + let voiceAnalyserContext = null; + let voiceAnalyser = null; + const peerConnections = {}; + const remoteAudios = {}; + let audioUnlocked = false; + + function startVoiceActivityDetection(stream) { + if (!stream || !stream.getTracks().length) return; + try { + var ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === 'suspended') ctx.resume().catch(function () {}); + var src = ctx.createMediaStreamSource(stream); + var analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + src.connect(analyser); + voiceAnalyserContext = ctx; + voiceAnalyser = analyser; + var dataArray = new Uint8Array(analyser.frequencyBinCount); + var lastEmit = 0; + voiceActivityInterval = setInterval(function () { + if (!voiceAnalyser || !stream.active) return; + voiceAnalyser.getByteFrequencyData(dataArray); + var sum = 0; + for (var i = 0; i < dataArray.length; i++) sum += dataArray[i]; + var avg = sum / dataArray.length; + var level = Math.min(1, avg / 60); + if (level > 0.08 && Date.now() - lastEmit > 80) { + lastEmit = Date.now(); + socket.emit('voice-activity', { level: level }); + var me = peers.get(socket.id); + if (me) { + me.speakingUntil = Date.now() + 400; + me.speakingLevel = level; + redrawLobbyMap(); + } + } + }, 100); + } catch (e) { console.warn('Voice activity detection:', e); } + } + function stopVoiceActivityDetection() { + if (voiceActivityInterval) { clearInterval(voiceActivityInterval); voiceActivityInterval = null; } + if (voiceAnalyserContext) { try { voiceAnalyserContext.close(); } catch (e) {} voiceAnalyserContext = null; } + voiceAnalyser = null; + } + function unlockAudio() { + if (audioUnlocked) return; + try { + var a = new Audio(); + a.volume = 0; + a.play().then(function() { audioUnlocked = true; playAllRemoteAudios(); }).catch(function() {}); + } catch (e) {} + } + function playAllRemoteAudios() { + Object.keys(remoteAudios).forEach(function(peerId) { tryPlayPeer(peerId); }); + } + function tryPlayPeer(peerId) { + var el = remoteAudios[peerId]; + if (el && el.srcObject) el.play().catch(function() {}); + } + + function addRemoteAudio(peerId, stream) { + if (!stream || !stream.getTracks().length) return; + unlockAudio(); + + if (remoteAudios[peerId]) { + remoteAudios[peerId].srcObject = stream; + tryPlayPeer(peerId); + return; + } + + var el = document.createElement('audio'); + el.autoplay = true; + el.setAttribute('playsinline', ''); + el.volume = 1; + el.srcObject = stream; + el.style.cssText = 'position:absolute;width:0;height:0;opacity:0;pointer-events:none;'; + remoteAudios[peerId] = el; + (document.getElementById('chat-messages') || document.body).appendChild(el); + + el.onloadedmetadata = function() { tryPlayPeer(peerId); }; + el.oncanplay = function() { tryPlayPeer(peerId); }; + tryPlayPeer(peerId); + } + + function setupUnlockOnFirstClick() { + var once = function() { + unlockAudio(); + document.body.removeEventListener('click', once); + }; + document.body.addEventListener('click', once, { once: true }); + } + + socket.on('peer-voice-state', ({ id, micOn }) => { + const p = peers.get(id); + if (p) { p.voiceMicOn = micOn !== false; redrawLobbyMap(); } + }); + + socket.on('peer-speaking', function (data) { + var id = data.id, level = data.level, until = data.until; + var p = peers.get(id); + if (p) { + p.speakingUntil = until; + p.speakingLevel = level; + redrawLobbyMap(); + } + }); + + const iceQueue = {}; + const defaultIceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + let cachedIceServers = null; + async function getIceServers() { + if (cachedIceServers) return cachedIceServers; + try { + const r = await fetch(SERVER + '/api/ice-servers'); + const d = await r.json(); + if (d && d.iceServers && d.iceServers.length) cachedIceServers = d.iceServers; + else cachedIceServers = defaultIceServers; + } catch (e) { cachedIceServers = defaultIceServers; } + return cachedIceServers; + } + async function createPeerConnection(peerId, fromOffer) { + if (peerConnections[peerId]) return peerConnections[peerId]; + const iceServers = await getIceServers(); + const pc = new RTCPeerConnection({ iceServers: iceServers }); + pc._isInitiator = !fromOffer; + if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); + pc.ontrack = (e) => { + if (e.track.kind !== 'audio') return; + e.track.enabled = true; + const stream = (e.streams && e.streams[0]) ? e.streams[0] : new MediaStream([e.track]); + if (window.DEBUG_VOICE) console.log('[voice] ontrack จาก', peerId, 'streamId=', stream.id, 'track=', e.track.id); + addRemoteAudio(peerId, stream); + }; + pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-signal', { to: peerId, type: 'ice', candidate: e.candidate }); }; + pc.oniceconnectionstatechange = () => { + if (window.DEBUG_VOICE) console.log('[voice] ICE', peerId, pc.iceConnectionState); + if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') tryPlayPeer(peerId); + }; + pc.onnegotiationneeded = async () => { + if (!pc._isInitiator) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (err) { console.warn('WebRTC offer error', err); } + }; + peerConnections[peerId] = pc; + iceQueue[peerId] = []; + return pc; + } + async function drainIceQueue(peerId) { + const pc = peerConnections[peerId]; + const q = iceQueue[peerId]; + if (!pc || !q || q.length === 0) return; + while (q.length > 0) { + const c = q.shift(); + try { await pc.addIceCandidate(new RTCIceCandidate(c)); } catch (e) { console.warn('addIceCandidate', e); } + } + } + + function closePeer(peerId) { + const pc = peerConnections[peerId]; + if (pc) { pc.close(); delete peerConnections[peerId]; } + var el = remoteAudios[peerId]; + if (el) { + el.srcObject = null; + el.remove(); + delete remoteAudios[peerId]; + } + } + + socket.on('webrtc-signal', async (data) => { + const { from, type, sdp, candidate } = data; + if (!from || from === socket.id) return; + try { + const sdpObj = (sdp && (sdp.sdp !== undefined)) ? sdp : (sdp ? { type: sdp.type, sdp: sdp.sdp || '' } : null); + if (type === 'offer' && sdpObj) { + const pc = await createPeerConnection(from, true); + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('webrtc-signal', { to: from, type: 'answer', sdp: { type: answer.type, sdp: answer.sdp } }); + await drainIceQueue(from); + } else if (type === 'answer' && sdpObj) { + const pc = peerConnections[from]; + if (pc) { + await pc.setRemoteDescription(new RTCSessionDescription(sdpObj)); + await drainIceQueue(from); + } + } else if (type === 'ice' && candidate) { + const pc = peerConnections[from]; + if (pc) { + if (pc.remoteDescription) { + try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { (iceQueue[from] = iceQueue[from] || []).push(candidate); } + } else (iceQueue[from] = iceQueue[from] || []).push(candidate); + } + } + } catch (err) { console.warn('WebRTC signal error', err); } + }); + + socket.on('host-changed', (data) => { + hostId = data.hostId || null; + const isHost = hostId === socket.id; + if (hostOnly) hostOnly.style.display = isHost ? 'flex' : 'none'; + if (nonHostMsg) nonHostMsg.style.display = isHost ? 'none' : 'inline'; + renderPeers(); + if (isHost && playMapSelect) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + playMapSelect.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name || m.id; + if (m.name) opt.dataset.mapName = m.name; + playMapSelect.appendChild(opt); + }); + }) + .catch(() => { playMapSelect.innerHTML = ''; }); + } + updateHostStartGameButton(); + ensureReadyControlEnabled(); + updateSuspectHostUi(); + updateSuspectFloatingOpenBtn(); + refreshHostConsoleOverlayIfOpen(); + }); + + socket.off('user-left'); + socket.on('user-left', (data) => { + closePeer(data.id); + peers.delete(data.id); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + }); + + const btnVoice = document.getElementById('btn-voice'); + const voiceDeviceSelect = document.getElementById('voice-device-select'); + + function setVoiceButtonIcon(btn, micOn) { + if (!btn) return; + const img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img'); + if (img) { + img.src = micOn ? SERVER + '/img/btn-mic-on.png' : SERVER + '/img/btn-mic-mute.png'; + img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; + } else { + btn.textContent = micOn ? '🔇 ปิดเสียง' : '🔊 เปิดเสียง'; + } + btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด'; + } + + async function populateVoiceDevices() { + if (!voiceDeviceSelect) return; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + const inputs = devs.filter(d => d.kind === 'audioinput'); + voiceDeviceSelect.innerHTML = ''; + if (inputs.length === 0) { + voiceDeviceSelect.innerHTML = ''; + return; + } + voiceDeviceSelect.appendChild(new Option('ไมค์เริ่มต้น (default)', '')); + inputs.forEach(d => { + voiceDeviceSelect.appendChild(new Option(d.label || 'ไมค์ ' + (voiceDeviceSelect.options.length), d.deviceId)); + }); + } catch (e) { + voiceDeviceSelect.innerHTML = ''; + } + } + + if (voiceDeviceSelect) { + populateVoiceDevices(); + navigator.mediaDevices.addEventListener('devicechange', populateVoiceDevices); + } + + let troublesomeEligibleSent = false; + function tryTroublesomeLobbyGate() { + if (!isPostCaseLobbyRoom()) return; + const howtoEl = document.getElementById('room-howto-overlay'); + const micEl = document.getElementById('mic-permission-overlay'); + const howtoOk = !howtoEl || howtoEl.classList.contains('hidden') || howtoEl.classList.contains('is-hidden'); + const micOk = !micEl || micEl.classList.contains('hidden') || micEl.classList.contains('is-hidden'); + if (!howtoOk || !micOk) return; + if (troublesomeEligibleSent) return; + troublesomeEligibleSent = true; + socket.emit('troublesome-eligible'); + } + + function hideMicPermissionOverlay() { + var el = document.getElementById('mic-permission-overlay'); + if (el) el.classList.add('hidden'); + tryTroublesomeLobbyGate(); + } + + const roomHowtoOverlay = document.getElementById('room-howto-overlay'); + const roomHowtoBtnGotIt = document.getElementById('room-howto-btn-got-it'); + if (roomHowtoBtnGotIt) { + roomHowtoBtnGotIt.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.add('hidden'); + roomHowtoOverlay.classList.add('is-hidden'); + } + tryTroublesomeLobbyGate(); + }); + } + document.getElementById('btn-room-howto')?.addEventListener('click', function () { + if (roomHowtoOverlay) { + roomHowtoOverlay.classList.remove('hidden'); + roomHowtoOverlay.classList.remove('is-hidden'); + } + }); + + var micPermissionOverlay = document.getElementById('mic-permission-overlay'); + var micPermissionAllow = document.getElementById('mic-permission-allow'); + var micPermissionClose = document.getElementById('mic-permission-close'); + if (micPermissionAllow) { + micPermissionAllow.addEventListener('click', async function () { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + hideMicPermissionOverlay(); + return; + } + micPermissionAllow.disabled = true; + micPermissionAllow.title = 'กำลังขอสิทธิ์...'; + try { + var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(function (t) { t.stop(); }); + if (typeof populateVoiceDevices === 'function') await populateVoiceDevices(); + hideMicPermissionOverlay(); + var permBtn = document.getElementById('btn-voice-permission'); + if (permBtn) { permBtn.textContent = '✓ อนุญาตแล้ว'; permBtn.disabled = true; } + } catch (err) { + micPermissionAllow.disabled = false; + micPermissionAllow.title = 'อนุญาต'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + if (micPermissionClose) { + micPermissionClose.addEventListener('click', function () { + hideMicPermissionOverlay(); + }); + } + + const btnVoicePermission = document.getElementById('btn-voice-permission'); + if (btnVoicePermission) { + btnVoicePermission.addEventListener('click', async () => { + unlockAudio(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('เบราว์เซอร์นี้ไม่รองรับการขอสิทธิ์ไมค์'); + return; + } + btnVoicePermission.disabled = true; + btnVoicePermission.textContent = 'กำลังขอสิทธิ์...'; + let stream = null; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (stream) stream.getTracks().forEach(t => t.stop()); + await populateVoiceDevices(); + btnVoicePermission.textContent = '✓ อนุญาตแล้ว'; + hideMicPermissionOverlay(); + } catch (err) { + btnVoicePermission.disabled = false; + btnVoicePermission.textContent = '🎤 ขอสิทธิ์ไมค์'; + alert('ไมค์ถูกปฏิเสธหรือไม่พบอุปกรณ์: ' + (err.message || err)); + } + }); + } + + var btnHear = document.getElementById('btn-hear'); + if (btnHear) { + btnHear.addEventListener('click', function() { + unlockAudio(); + playAllRemoteAudios(); + btnHear.textContent = '✓ เปิดรับเสียงแล้ว'; + btnHear.disabled = true; + }); + } + setupUnlockOnFirstClick(); + + if (btnVoice) { + btnVoice.addEventListener('click', async () => { + unlockAudio(); + if (localStream) { + stopVoiceActivityDetection(); + Object.keys(peerConnections).forEach(peerId => { + const pc = peerConnections[peerId]; + if (pc && pc.getSenders) { + pc.getSenders().forEach(sender => { try { pc.removeTrack(sender); } catch (e) {} }); + } + }); + localStream.getTracks().forEach(t => t.stop()); + localStream = null; + socket.emit('voice-state', { micOn: false }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = false; + setVoiceButtonIcon(btnVoice, false); + return; + } + const deviceId = voiceDeviceSelect && voiceDeviceSelect.value ? voiceDeviceSelect.value.trim() : ''; + const audioConstraints = { + audio: deviceId + ? { deviceId: { ideal: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } + : { echoCancellation: true, noiseSuppression: true, autoGainControl: true } + }; + try { + localStream = await navigator.mediaDevices.getUserMedia(audioConstraints); + startVoiceActivityDetection(localStream); + await populateVoiceDevices(); + setVoiceButtonIcon(btnVoice, true); + socket.emit('voice-state', { micOn: true }); + const me = peers.get(socket.id); + if (me) me.voiceMicOn = true; + for (const peerId of peers.keys()) { + if (peerId === socket.id) continue; + const pc = await createPeerConnection(peerId); + if (!localStream || !pc) continue; + const senders = pc.getSenders(); + let added = false; + localStream.getTracks().forEach(track => { + const hasTrack = senders.find(s => s.track === track); + if (!hasTrack) { pc.addTrack(track, localStream); added = true; } + }); + if (added) { + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('webrtc-signal', { to: peerId, type: 'offer', sdp: { type: offer.type, sdp: offer.sdp } }); + } catch (e) { console.warn('WebRTC renegotiate offer error', e); } + } + } + } catch (err) { + alert('ไม่สามารถเปิดไมค์ได้: ' + (err.message || err)); + } + }); + } + + function getLobbyEvidenceCasePayload() { + try { + const meta = window.__detectiveLobbyMeta; + let cid = meta && meta.caseId != null ? String(meta.caseId).trim() : '1'; + if (!/^[123]$/.test(cid)) cid = '1'; + return LOBBY_EVIDENCE_CASES[cid] || LOBBY_EVIDENCE_CASES['1']; + } catch (e) { + return LOBBY_EVIDENCE_CASES['1']; + } + } + + function setLobbyEvidenceTabImage(idx) { + const img = document.getElementById('lobby-evidence-tabs-img'); + if (!img) return; + const n = Math.max(0, Math.min(2, idx)) + 1; + img.src = `${LOBBY_EVIDENCE_ASSET_BASE}/evidence-tab-${n}.png`; + } + + function renderLobbyEvidenceCards(suspectIdx) { + const root = document.getElementById('lobby-evidence-cards-root'); + if (!root) return; + const payload = getLobbyEvidenceCasePayload(); + const suspects = payload.suspects || []; + const si = Math.max(0, Math.min(suspects.length - 1, suspectIdx)); + const s = suspects[si]; + root.textContent = ''; + if (!s || !s.cards) return; + s.cards.forEach((c) => { + let rar = String(c.rarity || 'common').toLowerCase(); + if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; + const nStar = Math.max(1, Math.min(3, Number(c.stars) || 1)); + let stars = ''; + for (let st = 0; st < nStar; st++) stars += '★'; + + const card = document.createElement('article'); + card.className = `lobby-evidence-card lobby-evidence-card--${rar}`; + + const head = document.createElement('div'); + head.className = 'lobby-evidence-card-head'; + const icon = document.createElement('span'); + icon.className = 'lobby-evidence-card-icon'; + icon.textContent = '🔎'; + icon.setAttribute('aria-hidden', 'true'); + const titles = document.createElement('div'); + titles.className = 'lobby-evidence-card-titles'; + const hTh = document.createElement('p'); + hTh.className = 'lobby-evidence-card-title-th'; + hTh.textContent = c.titleTh || ''; + const hEn = document.createElement('p'); + hEn.className = 'lobby-evidence-card-title-en'; + hEn.textContent = c.titleEn ? `(${c.titleEn})` : ''; + titles.appendChild(hTh); + titles.appendChild(hEn); + head.appendChild(icon); + head.appendChild(titles); + + const art = document.createElement('div'); + art.className = 'lobby-evidence-card-art'; + art.setAttribute('aria-hidden', 'true'); + art.textContent = '◆'; + + const body = document.createElement('p'); + body.className = 'lobby-evidence-card-body'; + body.textContent = c.body || ''; + + const foot = document.createElement('div'); + foot.className = 'lobby-evidence-card-foot'; + const link = document.createElement('div'); + link.className = 'lobby-evidence-link'; + const av = document.createElement('span'); + av.className = 'lobby-evidence-avatar'; + const nm = s.linkName || '?'; + av.textContent = nm.charAt(0); + const nmEl = document.createElement('span'); + nmEl.className = 'lobby-evidence-link-name'; + nmEl.textContent = nm; + link.appendChild(av); + link.appendChild(nmEl); + const fm = document.createElement('div'); + fm.className = 'lobby-evidence-foot-meta'; + const rl = document.createElement('span'); + rl.className = 'lobby-evidence-rarity'; + rl.textContent = `(${LOBBY_EVIDENCE_RARITY[rar]})`; + const stEl = document.createElement('div'); + stEl.className = 'lobby-evidence-stars'; + stEl.textContent = stars; + fm.appendChild(rl); + fm.appendChild(stEl); + foot.appendChild(link); + foot.appendChild(fm); + + card.appendChild(head); + card.appendChild(art); + card.appendChild(body); + card.appendChild(foot); + root.appendChild(card); + }); + } + + let lobbyEvidenceSuspectIdx = 0; + function syncLobbyEvidenceTabUi(idx) { + lobbyEvidenceSuspectIdx = Math.max(0, Math.min(2, idx)); + setLobbyEvidenceTabImage(lobbyEvidenceSuspectIdx); + document.querySelectorAll('[data-evidence-tab]').forEach((b) => { + const i = parseInt(b.getAttribute('data-evidence-tab'), 10); + b.setAttribute('aria-selected', i === lobbyEvidenceSuspectIdx ? 'true' : 'false'); + }); + renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + } + + function openLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + syncLobbyEvidenceTabUi(0); + document.getElementById('lobby-evidence-close')?.focus(); + } + + function closeLobbyEvidenceModal() { + const ov = document.getElementById('lobby-evidence-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-evidence')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyEvidenceModal(); + }); + + document.getElementById('lobby-evidence-backdrop')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-close')?.addEventListener('click', () => { + closeLobbyEvidenceModal(); + }); + document.getElementById('lobby-evidence-overlay')?.addEventListener('click', (e) => { + const hit = e.target.closest('[data-evidence-tab]'); + if (!hit) return; + const i = parseInt(hit.getAttribute('data-evidence-tab'), 10); + if (!Number.isNaN(i)) syncLobbyEvidenceTabUi(i); + }); + const LOBBY_RANK_MEDAL_BASE = typeof appPath === 'function' ? appPath('/Main-Lobby/IMAGE') : '/Main-Lobby/IMAGE'; + const LOBBY_RANK_MOCK_LEADERS = [ + { name: 'Golden L.', score: 9951 }, + { name: 'Mixie Kim', score: 9878 }, + { name: 'Leena', score: 9800 }, + { name: 'JeeJee256', score: 9785 }, + { name: 'Peach M.', score: 9762 }, + { name: 'Pony', score: 9720 }, + { name: 'Jubjib S.', score: 9688 }, + { name: 'Miss Berlin', score: 9620 }, + { name: 'Lemon Honey', score: 9560 }, + { name: 'Rosae BP.', score: 9500 }, + ]; + + function getLobbyRankCaseTitle() { + try { + const meta = window.__detectiveLobbyMeta; + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') return 'คดีปล้นร้านอัญมณี'; + if (cid === '3') return 'คดีฆาตกรรมปริศนา'; + return 'คดีโจรกรรมไซเบอร์'; + } catch (e) { + return 'คดีโจรกรรมไซเบอร์'; + } + } + + function buildRankPedestal(rank, entry, medalFile, pedClass) { + const wrap = document.createElement('div'); + wrap.className = `lobby-rank-ped ${pedClass}`; + if (rank === 1) { + const crown = document.createElement('div'); + crown.className = 'lobby-rank-crown'; + crown.innerHTML = '1'; + wrap.appendChild(crown); + } + const medWrap = document.createElement('div'); + medWrap.className = 'lobby-rank-medal-wrap'; + const img = document.createElement('img'); + img.className = 'lobby-rank-medal-img'; + img.src = `${LOBBY_RANK_MEDAL_BASE}/${medalFile}`; + img.alt = `เหรียญอันดับ ${rank}`; + medWrap.appendChild(img); + wrap.appendChild(medWrap); + const av = document.createElement('div'); + av.className = 'lobby-rank-ped-avatar'; + av.textContent = (entry.name || '?').charAt(0); + wrap.appendChild(av); + const nm = document.createElement('div'); + nm.className = 'lobby-rank-ped-name'; + nm.textContent = entry.name || ''; + wrap.appendChild(nm); + const sc = document.createElement('div'); + sc.className = 'lobby-rank-ped-score'; + sc.textContent = String(entry.score); + wrap.appendChild(sc); + return wrap; + } + + function renderLobbyRankModalContent() { + const titleEl = document.getElementById('lobby-rank-case-title'); + if (titleEl) titleEl.textContent = getLobbyRankCaseTitle(); + const sorted = [...LOBBY_RANK_MOCK_LEADERS].sort((a, b) => b.score - a.score); + const podium = document.getElementById('lobby-rank-podium'); + const tbody = document.getElementById('lobby-rank-tbody'); + const selfFoot = document.getElementById('lobby-rank-self'); + if (!podium || !tbody || !selfFoot) return; + podium.textContent = ''; + const first = sorted[0]; + const second = sorted[1]; + const third = sorted[2]; + if (second) podium.appendChild(buildRankPedestal(2, second, 'leaderboard-2.png', 'lobby-rank-ped--2')); + if (first) podium.appendChild(buildRankPedestal(1, first, 'leaderboard-1.png', 'lobby-rank-ped--1')); + if (third) podium.appendChild(buildRankPedestal(3, third, 'leaderboard-3.png', 'lobby-rank-ped--3')); + tbody.textContent = ''; + for (let i = 3; i < sorted.length && i < 10; i++) { + const row = sorted[i]; + const tr = document.createElement('tr'); + const tdR = document.createElement('td'); + tdR.textContent = String(i + 1); + const tdN = document.createElement('td'); + const wrap = document.createElement('div'); + wrap.className = 'lobby-rank-row-av'; + const mav = document.createElement('span'); + mav.className = 'lobby-rank-mini-av'; + mav.textContent = (row.name || '?').charAt(0); + const sp = document.createElement('span'); + sp.className = 'lobby-rank-row-name'; + sp.textContent = row.name || ''; + sp.title = row.name || ''; + wrap.appendChild(mav); + wrap.appendChild(sp); + tdN.appendChild(wrap); + const tdS = document.createElement('td'); + tdS.textContent = String(row.score); + tr.appendChild(tdR); + tr.appendChild(tdN); + tr.appendChild(tdS); + tbody.appendChild(tr); + } + selfFoot.textContent = ''; + const selfRank = document.createElement('span'); + selfRank.className = 'lobby-rank-self-rank'; + selfRank.textContent = '28'; + const selfAv = document.createElement('div'); + selfAv.className = 'lobby-rank-self-av'; + selfAv.textContent = (nick || '?').charAt(0); + const meta = document.createElement('div'); + meta.className = 'lobby-rank-self-meta'; + const selfName = document.createElement('div'); + selfName.className = 'lobby-rank-self-name'; + selfName.textContent = nick || 'ผู้เล่น'; + meta.appendChild(selfName); + const selfScore = document.createElement('div'); + selfScore.className = 'lobby-rank-self-score'; + selfScore.textContent = '7250'; + selfFoot.appendChild(selfRank); + selfFoot.appendChild(selfAv); + selfFoot.appendChild(meta); + selfFoot.appendChild(selfScore); + } + + function openLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + renderLobbyRankModalContent(); + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + document.getElementById('lobby-rank-close')?.focus(); + } + + function closeLobbyRankModal() { + const ov = document.getElementById('lobby-rank-overlay'); + if (!ov) return; + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + + document.getElementById('lobby-b-btn-rank')?.addEventListener('click', () => { + if (!isPostCaseLobbyRoom()) return; + openLobbyRankModal(); + }); + document.getElementById('btn-profile-set')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('btn-setting-host')?.addEventListener('click', () => { + if (hostId !== socket.id) { + alert('เฉพาะ Host เท่านั้น'); + return; + } + hostConsoleOverlay.open(); + }); + document.getElementById('btn-customize')?.addEventListener('click', () => { + roomLobbyProfileOverlay.open(); + }); + document.getElementById('room-lobby-profile-logout')?.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + document.getElementById('lobby-rank-backdrop')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + document.getElementById('lobby-rank-close')?.addEventListener('click', () => { + closeLobbyRankModal(); + }); + + socket.off('user-joined'); + socket.on('user-joined', async (data) => { + peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); + updatePlayersHud(); + renderPeers(); + ensureReadyControlEnabled(); + redrawLobbyMap(); + refreshHostConsoleOverlayIfOpen(); + if (localStream && data.id !== socket.id) await createPeerConnection(data.id); + }); + + let troublesomeTickTimer = null; + const troublesomeTimerPausedForTuning = false; + function closeTroublesomeOverlay() { + const ov = document.getElementById('troublesome-overlay'); + if (ov) ov.classList.add('is-hidden'); + if (troublesomeTickTimer) { + clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + } + } + + function refreshSuspectCaseLabel() { + const el = document.getElementById('suspect-case-label'); + if (!el) return; + let meta = null; + try { meta = window.__detectiveLobbyMeta; } catch (e) { meta = null; } + const cid = meta && meta.caseId != null ? String(meta.caseId).trim() : ''; + if (cid === '2') el.textContent = 'คดีปล้นร้านอัญมณี'; + else if (cid === '3') el.textContent = 'คดีฆาตกรรมปริศนา'; + else el.textContent = 'คดีโจรกรรมไซเบอร์'; + } + + function setSuspectCardMinigames(cards) { + if (!cards || !cards.length) return; + suspectCardMinigames = cards; + } + + var SUSPECT_EVID_FULL = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card.png'; // เก็บหลักฐานแล้ว (ติกถูก) + var SUSPECT_EVID_EMPTY = '/Main-Lobby/IMAGE/Choose%20a%20suspect/symbol-card-bg.png'; // ช่องว่าง (?) + + /** อัปเดต suspectProgress จากเซิร์ฟ แล้ววาดติกถูกใหม่ */ + function setSuspectProgress(arr) { + if (!Array.isArray(arr)) return; + suspectProgress = [0, 1, 2].map(function (i) { + var v = Math.floor(Number(arr[i])); + return (Number.isFinite(v) && v > 0) ? Math.min(3, v) : 0; + }); + applySuspectProgressVisual(); + if (suspectPickOverlayOpen) updateSuspectHostUi(); // อัปเดตปุ่มชี้ตัว (โชว์เมื่อสืบครบ) + } + + /** วางติกถูกในช่อง "หลักฐาน" (.suspect-symbol-list) ตามจำนวนที่เก็บได้ */ + function applySuspectProgressVisual() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var slots = card.querySelectorAll('.suspect-symbol-list img'); + var done = Math.max(0, Math.min(3, (suspectProgress && suspectProgress[i]) || 0)); + for (var s = 0; s < slots.length; s++) { + var want = (s < done) ? SUSPECT_EVID_FULL : SUSPECT_EVID_EMPTY; + if (slots[s].getAttribute('src') !== want) slots[s].src = want; + } + } + } + + /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย (ใช้ overlay เดิม) ===== */ + function injectTrialStyle() { + if (document.getElementById('trial-style')) return; + var st = document.createElement('style'); + st.id = 'trial-style'; + st.textContent = + '.suspect-pick--trial .suspect-pick-actions{display:none!important}' + + '.suspect-pick--trial #suspect-btn-accuse{display:none!important}' + /* #1 ปุ่มชี้ตัวต้องหายตอนหน้าโหวต */ + '#trial-vote-counter{position:absolute;right:22px;top:14px;color:#7fe9ff;font:800 24px/1 Kanit,system-ui,sans-serif;display:none;z-index:62;text-shadow:0 0 12px rgba(34,211,238,.7)}' + + '#trial-vote-counter b{color:#fff}' + + '.suspect-pick--trial #trial-vote-counter{display:block}' + + '.suspect-pick--trial .suspect-pick-title-img{display:none!important}' + + '#trial-title{display:none;text-align:center;color:#7fe9ff;font:800 40px/1.1 Kanit,system-ui,sans-serif;text-shadow:0 0 18px rgba(34,211,238,.7);margin:0 0 2px}' + + '#trial-title small{display:block;color:#cfe9ff;font:600 18px/1.3 Kanit,system-ui,sans-serif;text-shadow:none;margin-top:6px}' + + '.suspect-pick--trial #trial-title{display:block}' + + '.suspect-pick--trial .suspect-card{cursor:pointer;position:relative}' + + // badge อยู่กลางล่างการ์ด (เลียนแบบปุ่ม "เลือกแล้ว" ใน mockup) + '.suspect-card .trial-vote-badge{position:absolute;left:50%;bottom:12px;transform:translateX(-50%);color:#06121f;font:700 15px/1 Kanit,system-ui,sans-serif;padding:7px 16px;border-radius:999px;display:none;z-index:6;white-space:nowrap;box-shadow:0 2px 10px rgba(0,0,0,.45)}' + + // ตอนโหวต: โชว์เฉพาะใบที่เราเลือก = "เลือกแล้ว" (เขียว) + '.suspect-pick--trial .suspect-card--myvote .trial-vote-badge{display:block;background:rgba(34,197,94,.97)}' + + // ตอนเปิดเผยผล: โชว์คะแนนโหวตทุกใบ (ฟ้า) + '.suspect-pick--trial.trial-revealed .trial-vote-badge{display:block;background:rgba(34,211,238,.97)}' + + '.suspect-card--myvote{outline:3px solid #22c55e;outline-offset:-3px;border-radius:16px}' + + '.suspect-card--culprit{outline:4px solid #f7768e;outline-offset:-4px;border-radius:16px;box-shadow:0 0 34px rgba(247,118,142,.85)}' + + '#trial-result-banner{position:absolute;left:50%;top:5%;transform:translateX(-50%);background:rgba(6,12,24,.96);border:2px solid #22d3ee;border-radius:16px;padding:14px 26px;color:#e7ecff;font:600 20px/1.5 Kanit,system-ui,sans-serif;text-align:center;z-index:60;max-width:82%;box-shadow:0 8px 40px rgba(0,0,0,.6)}' + + '#trial-reveal-btn{position:absolute;left:50%;bottom:5%;transform:translateX(-50%);background:linear-gradient(180deg,#22d3ee,#0e7490);color:#06121f;border:none;border-radius:14px;padding:14px 30px;font:700 20px/1 Kanit,system-ui,sans-serif;cursor:pointer;z-index:60;box-shadow:0 6px 22px rgba(34,211,238,.5)}'; + document.head.appendChild(st); + } + + function ensureTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + if (!card.querySelector('.trial-vote-badge')) { + var b = document.createElement('span'); + b.className = 'trial-vote-badge'; + b.textContent = ''; + card.appendChild(b); + } + } + } + + function applyTrialBadges() { + for (var i = 0; i <= 2; i++) { + var card = document.querySelector('.suspect-card[data-index="' + i + '"]'); + if (!card) continue; + var b = card.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[i]) || 0); + } + } + + function ensureTrialBanner() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-result-banner'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-result-banner'; + el.style.display = 'none'; + ov.appendChild(el); + } + return el; + } + + function ensureTrialCounter() { + var ov = document.getElementById('suspect-pick-overlay'); + var el = document.getElementById('trial-vote-counter'); + if (!el && ov) { + el = document.createElement('div'); + el.id = 'trial-vote-counter'; + ov.appendChild(el); + } + return el; + } + + function setTrialCounter(voted, total) { + var el = ensureTrialCounter(); + if (el) el.innerHTML = 'ลงติกแล้ว : ' + (voted || 0) + '/' + (total || 0) + ''; + } + + function ensureTrialRevealBtn() { + var ov = document.getElementById('suspect-pick-overlay'); + var btn = document.getElementById('trial-reveal-btn'); + if (!btn && ov) { + btn = document.createElement('button'); + btn.id = 'trial-reveal-btn'; + btn.type = 'button'; + btn.textContent = 'เปิดเผยผล'; + btn.addEventListener('click', function () { + socket.emit('trial-reveal', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดเผยผลไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + ov.appendChild(btn); + } + if (btn) btn.style.display = (trialMode === 'voting' && hostId === socket.id) ? 'block' : 'none'; + } + + /** หัวข้อ "พิจารณาคดี" (แทนรูป title เลือกผู้ต้องสงสัยตอนอยู่โหมดโหวต) */ + function ensureTrialTitle() { + var row = document.getElementById('suspect-cards-row'); + var t = document.getElementById('trial-title'); + if (!t && row && row.parentNode) { + t = document.createElement('div'); + t.id = 'trial-title'; + t.innerHTML = 'พิจารณาคดีเลือกผู้ร้ายตัวจริง และส่งตัวไปดำเนินคดี เพื่อทำภารกิจให้สำเร็จ'; + row.parentNode.insertBefore(t, row); + } + return t; + } + + function setTrialSubtitle(txt) { + var t = document.getElementById('trial-title'); + if (!t) return; + var s = t.querySelector('small'); + if (s) s.textContent = txt; + } + + function castTrialVote(idx) { + if (trialMode !== 'voting') return; + myTrialVote = idx; + document.querySelectorAll('.suspect-card').forEach(function (c) { + var sel = parseInt(c.getAttribute('data-index'), 10) === idx; + c.classList.toggle('suspect-card--myvote', sel); + var b = c.querySelector('.trial-vote-badge'); + if (b && sel) b.textContent = '✓ เลือกแล้ว'; + }); + socket.emit('trial-vote', { index: idx }, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— โหวตไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + } + + function openTrialVoteUI(data) { + trialMode = 'voting'; + myTrialVote = null; + trialVoteCounts = [0, 0, 0]; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) suspectProgress = [0, 1, 2].map(function (i) { return Math.min(3, Math.max(0, Math.floor(Number(data.suspectProgress[i])) || 0)); }); + injectTrialStyle(); + var ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + suspectPickOverlayOpen = true; + serverSuspectPhaseActive = false; + ov.classList.remove('is-hidden', 'trial-revealed'); + ov.classList.add('suspect-pick--trial'); + ov.setAttribute('aria-hidden', 'false'); + document.querySelectorAll('.suspect-card').forEach(function (c) { + c.classList.remove('suspect-card--myvote', 'suspect-card--culprit', 'suspect-card--selected'); + }); + applySuspectProgressVisual(); + ensureTrialTitle(); + ensureTrialBadges(); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + setTrialCounter(0, (data && data.totalPlayers) || (peers ? peers.size : 0) || 1); + // กันปุ่มชี้ตัวค้าง: ใส่ is-hidden ตอนเข้าหน้าโหวต + var accuseBtn0 = document.getElementById('suspect-btn-accuse'); + if (accuseBtn0) accuseBtn0.classList.add('is-hidden'); + ensureTrialRevealBtn(); + var banner = ensureTrialBanner(); + if (banner) banner.style.display = 'none'; + var actions = document.getElementById('suspect-pick-actions'); + if (actions) actions.classList.remove('suspect-pick-actions--visible'); + var hint = document.getElementById('suspect-pick-hint'); + if (hint) { hint.classList.remove('is-hidden'); hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; } + updateSuspectFloatingOpenBtn(); + scheduleSuspectPickScale(); + } + + function showTrialResult(data) { + trialMode = 'revealed'; + var culprit = (data && typeof data.culpritIndex === 'number') ? data.culpritIndex : 0; + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; + var ov = document.getElementById('suspect-pick-overlay'); + if (ov) ov.classList.add('trial-revealed'); + // โชว์คะแนนโหวตทุกใบ + ไฮไลต์คนร้าย + document.querySelectorAll('.suspect-card').forEach(function (c) { + var ci = parseInt(c.getAttribute('data-index'), 10); + c.classList.remove('suspect-card--myvote'); + c.classList.toggle('suspect-card--culprit', ci === culprit); + var b = c.querySelector('.trial-vote-badge'); + if (b) b.textContent = 'โหวต ' + ((trialVoteCounts && trialVoteCounts[ci]) || 0); + }); + setTrialSubtitle('ผลการพิจารณาคดี'); + var names = (data && data.winnerNames && data.winnerNames.length) ? data.winnerNames.join(', ') : 'ไม่มีใครตอบถูก'; + var iWon = !!(data && Array.isArray(data.winners) && data.winners.indexOf(socket.id) >= 0); + var banner = ensureTrialBanner(); + if (banner) { + banner.innerHTML = 'คนร้ายตัวจริงคือ ผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + '
' + + 'ผู้ตอบถูก: ' + names + (iWon ? '
🎉 คุณตอบถูก! +100' : ''); + banner.style.display = 'block'; + } + var rb = document.getElementById('trial-reveal-btn'); + if (rb) rb.style.display = 'none'; + var hint = document.getElementById('suspect-pick-hint'); + if (hint) hint.textContent = 'ปิดหน้าต่างเพื่อกลับโถง'; + appendLobbySystemChat('— เปิดเผยผล: คนร้ายคือผู้ต้องสงสัยหมายเลข ' + (culprit + 1) + ' · ผู้ตอบถูก: ' + names); + } + + function applySuspectSelectionVisual(idx) { + let i = Math.floor(Number(idx)); + if (Number.isNaN(i) || i < 0 || i > 2) i = 0; + suspectSelectedIndex = i; + document.querySelectorAll('.suspect-card').forEach((btn) => { + const j = parseInt(btn.getAttribute('data-index'), 10); + btn.classList.toggle('suspect-card--selected', j === i); + }); + } + + function applyDetectiveReturnToLobbyB(data) { + quizModeActive = false; + quizPhaseLocal = null; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + try { document.body.classList.remove('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + hideQuizOverlay(); + const mid = (data && data.mapId) ? String(data.mapId).trim() : POST_CASE_LOBBY_SPACE_ID; + const reopenSuspect = !!(data && data.suspectPhaseActive); + const pickIdx = (data && typeof data.suspectPickIndex === 'number') ? data.suspectPickIndex : suspectSelectedIndex; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + applyRoomLobbyBTransition({ + mapId: mid, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [], + lobbyLevel: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.level) || null, + caseId: (window.__detectiveLobbyMeta && window.__detectiveLobbyMeta.caseId) || null, + }); + if (reopenSuspect) { + serverSuspectPhaseActive = true; + setTimeout(function () { + openSuspectOverlay(pickIdx); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + }, 500); + } + syncLobbyBUiChrome(); + updatePlayersHud(); + appendLobbySystemChat('— จบมินิเกม · กลับ LobbyB แล้ว'); + } + + const SUSPECT_DESIGN_WIDTH = 1200; + let suspectPickScaleListenersBound = false; + + function scheduleSuspectPickScale() { + requestAnimationFrame(function () { + requestAnimationFrame(layoutSuspectPickScale); + }); + } + + function bindSuspectPickScaleListeners() { + if (suspectPickScaleListenersBound) return; + suspectPickScaleListenersBound = true; + window.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + } + document.querySelectorAll('#suspect-cards-row img, .suspect-pick-title-img, #suspect-btn-start img, #suspect-btn-accuse img').forEach(function (img) { + img.addEventListener('load', function () { + if (suspectPickOverlayOpen) scheduleSuspectPickScale(); + }); + }); + } + + function layoutSuspectPickScale() { + const ov = document.getElementById('suspect-pick-overlay'); + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (!ov || !wrap || !inner || ov.classList.contains('is-hidden') || !suspectPickOverlayOpen) return; + + inner.style.width = SUSPECT_DESIGN_WIDTH + 'px'; + inner.style.transform = 'none'; + const naturalH = inner.offsetHeight; + const padX = 24; + const padY = 40; + const availW = window.innerWidth - padX * 2; + const availH = window.innerHeight - padY; + const sW = availW / SUSPECT_DESIGN_WIDTH; + const sH = availH / Math.max(1, naturalH); + const s = Math.min(1, sW, sH); + inner.style.transformOrigin = 'top center'; + inner.style.transform = 'scale(' + s + ')'; + wrap.style.height = Math.ceil(naturalH * s) + 'px'; + wrap.style.overflow = 'hidden'; + } + + function lobbyMapQueryParam() { + try { + return (new URLSearchParams(location.search).get('map') || '').trim(); + } catch (e) { + return ''; + } + } + + function isPostCaseLobbyRoom() { + if (clientLobbyMapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (lobbyMapQueryParam() === POST_CASE_LOBBY_SPACE_ID) return true; + if (mapData) { + const nm = String(mapData.name || '').trim().toLowerCase(); + if (nm === 'lobbyb') return true; + } + if (spaceId === POST_CASE_LOBBY_SPACE_ID) return true; + return false; + } + + function syncLobbyBUiChrome() { + const on = isPostCaseLobbyRoom(); + try { + document.body.classList.toggle('room-lobby--lobby-b', on); + } catch (e) { /* ignore */ } + const row = document.getElementById('lobby-b-extra-row'); + if (row) { + row.classList.toggle('is-hidden', !on); + row.setAttribute('aria-hidden', on ? 'false' : 'true'); + } + } + + function updateSuspectFloatingOpenBtn() { + const btn = document.getElementById('btn-suspect-reopen'); + if (!btn) return; + const show = !!serverSuspectPhaseActive && !suspectPickOverlayOpen && isPostCaseLobbyRoom(); + btn.classList.toggle('is-hidden', !show); + } + + /** ชี้ตัวคนร้ายได้ต่อเมื่อสืบครบทั้ง 3 ผู้ต้องสงสัย (เล่นมินิเกมอย่างน้อยการ์ดละ 1 ครั้ง) */ + function allSuspectsInvestigated() { + return [0, 1, 2].every(function (i) { return ((suspectProgress && suspectProgress[i]) || 0) >= 1; }); + } + + function updateSuspectHostUi() { + if (!suspectPickOverlayOpen) return; + if (trialMode) { ensureTrialRevealBtn(); return; } // โหมดพิจารณาคดีคุม UI เอง + const isHost = hostId === socket.id; + const canAccuse = isHost && allSuspectsInvestigated(); + const actions = document.getElementById('suspect-pick-actions'); + const accuseBtn = document.getElementById('suspect-btn-accuse'); + const hint = document.getElementById('suspect-pick-hint'); + if (hint) { + hint.textContent = isHost + ? (allSuspectsInvestigated() + ? 'สืบครบทุกผู้ต้องสงสัยแล้ว — กด "ชี้ตัวคนร้าย" เพื่อเข้าสู่การพิจารณาคดี' + : 'เลือกการ์ดผู้ต้องสงสัยแล้วกดเริ่มสืบสวน (ต้องสืบให้ครบทั้ง 3 ก่อนชี้ตัวคนร้าย)') + : 'รอ Host เลือกการ์ดและกดเริ่มสืบสวน'; + hint.classList.toggle('is-hidden', false); + } + if (actions) { + actions.classList.toggle('suspect-pick-actions--visible', isHost); + actions.setAttribute('aria-hidden', isHost ? 'false' : 'true'); + } + if (accuseBtn) { + // โชว์ปุ่มชี้ตัวเฉพาะเมื่อ host + สืบครบ 3 ผู้ต้องสงสัยแล้ว + accuseBtn.classList.toggle('is-hidden', !canAccuse); + accuseBtn.setAttribute('aria-hidden', canAccuse ? 'false' : 'true'); + } + document.querySelectorAll('.suspect-card').forEach((c) => { + c.classList.toggle('suspect-card--host', isHost); + }); + scheduleSuspectPickScale(); + } + + function openSuspectOverlay(selectedIndex) { + const ov = document.getElementById('suspect-pick-overlay'); + if (!ov) return; + bindSuspectPickScaleListeners(); + refreshSuspectCaseLabel(); + suspectPickOverlayOpen = true; + ov.classList.remove('is-hidden'); + ov.setAttribute('aria-hidden', 'false'); + applySuspectSelectionVisual(typeof selectedIndex === 'number' ? selectedIndex : 0); + applySuspectProgressVisual(); + updateSuspectHostUi(); + updatePlayersHud(); + updateSuspectFloatingOpenBtn(); + } + + function closeSuspectOverlay() { + const ov = document.getElementById('suspect-pick-overlay'); + // รีเซ็ตสถานะพิจารณาคดี + if (trialMode) { + trialMode = null; myTrialVote = null; trialVoteCounts = [0, 0, 0]; + if (ov) ov.classList.remove('suspect-pick--trial', 'trial-revealed'); + document.querySelectorAll('.suspect-card').forEach(function (c) { c.classList.remove('suspect-card--myvote', 'suspect-card--culprit'); }); + document.querySelectorAll('.trial-vote-badge').forEach(function (b) { b.textContent = ''; }); + var banner = document.getElementById('trial-result-banner'); if (banner) banner.style.display = 'none'; + var rb = document.getElementById('trial-reveal-btn'); if (rb) rb.style.display = 'none'; + } + const wrap = document.getElementById('suspect-pick-scale-wrap'); + const inner = wrap && wrap.querySelector('.suspect-pick-inner'); + if (inner) { + inner.style.transform = ''; + inner.style.width = ''; + } + if (wrap) { + wrap.style.height = ''; + wrap.style.overflow = ''; + } + if (ov) { + ov.classList.add('is-hidden'); + ov.setAttribute('aria-hidden', 'true'); + } + suspectPickOverlayOpen = false; + syncLobbyBUiChrome(); + updateSuspectFloatingOpenBtn(); + } + + socket.on('troublesome-offer', (data) => { + const seconds = (data && Number(data.seconds)) > 0 ? Number(data.seconds) : 15; + const ov = document.getElementById('troublesome-overlay'); + const secEl = document.getElementById('troublesome-sec'); + if (!ov) return; + ov.classList.remove('is-hidden'); + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + let left = seconds; + if (secEl) secEl.textContent = String(left); + if (troublesomeTimerPausedForTuning) { + troublesomeTickTimer = null; + return; + } + troublesomeTickTimer = setInterval(() => { + left -= 1; + if (secEl) secEl.textContent = String(Math.max(0, left)); + if (left <= 0) { + if (troublesomeTickTimer) clearInterval(troublesomeTickTimer); + troublesomeTickTimer = null; + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + } + }, 1000); + }); + + const troublesomeDecline = document.getElementById('troublesome-decline'); + const troublesomeAccept = document.getElementById('troublesome-accept'); + if (troublesomeDecline) { + troublesomeDecline.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: false }); + closeTroublesomeOverlay(); + }); + } + if (troublesomeAccept) { + troublesomeAccept.addEventListener('click', () => { + socket.emit('troublesome-response', { accept: true }); + closeTroublesomeOverlay(); + }); + } + + socket.on('suspect-phase-open', (data) => { + serverSuspectPhaseActive = true; + if (data && data.hostId != null) hostId = data.hostId; + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + openSuspectOverlay(idx); + }); + + socket.on('suspect-pick-update', (data) => { + const idx = (data && typeof data.selectedIndex === 'number') ? data.selectedIndex : 0; + applySuspectSelectionVisual(idx); + }); + + // ===== ขั้นพิจารณาคดี ===== + socket.on('trial-open', (data) => { + openTrialVoteUI(data || {}); + }); + socket.on('trial-vote-update', (data) => { + if (data && Array.isArray(data.counts)) trialVoteCounts = data.counts; // เก็บไว้โชว์ตอนเปิดเผยผล (ไม่โชว์ต่อใบตอนโหวต) + if (trialMode === 'voting') setTrialCounter((data && data.voted) || 0, (data && data.totalPlayers) || 0); + const hint = document.getElementById('suspect-pick-hint'); + if (hint && trialMode === 'voting') { + hint.textContent = 'คลิกการ์ดที่คิดว่าเป็นคนร้าย'; + } + }); + socket.on('trial-result', (data) => { + showTrialResult(data || {}); + }); + + socket.on('suspect-investigation-start', (data) => { + serverSuspectPhaseActive = false; + closeSuspectOverlay(); + const n = (data && typeof data.selectedIndex === 'number') ? (data.selectedIndex + 1) : (suspectSelectedIndex + 1); + const mgLabel = (data && data.minigameLabel) ? String(data.minigameLabel) : ''; + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n + (mgLabel ? ' · ' + mgLabel : '')); + }); + + /** เลือกการ์ดผู้ต้องสงสัยเท่านั้น — ยังไม่เริ่มมินิเกม */ + function selectSuspectCard(idx) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (idx < 0 || idx > 2) return; + const prevIdx = suspectSelectedIndex; + applySuspectSelectionVisual(idx); + socket.emit('suspect-pick-select', { index: idx }); + if (prevIdx !== idx) { + appendLobbySystemChat('— เลือกผู้ต้องสงสัยหมายเลข ' + (idx + 1)); + } + } + + /** เริ่มมินิเกมตามการ์ดที่เลือกแล้ว — กดปุ่มเริ่มสืบสวน / ชี้ตัวคนร้าย */ + function beginSuspectInvestigation(sourceLabel) { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const idx = suspectSelectedIndex; + if (idx < 0 || idx > 2) return; + socket.emit('suspect-pick-start', {}, function (res) { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : (sourceLabel || 'เริ่มสืบสวนไม่สำเร็จ'); + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + } + + document.getElementById('suspect-cards-row')?.addEventListener('click', (ev) => { + const card = ev.target.closest('.suspect-card'); + if (!card || !suspectPickOverlayOpen) return; + const idx = parseInt(card.getAttribute('data-index'), 10); + if (!(idx >= 0 && idx <= 2)) return; + if (trialMode === 'voting') { castTrialVote(idx); return; } // โหมดโหวต: ทุกคนคลิกได้ + if (trialMode === 'revealed') return; + if (hostId !== socket.id) return; + selectSuspectCard(idx); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + beginSuspectInvestigation('เริ่มสืบสวนไม่สำเร็จ'); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + if (!allSuspectsInvestigated()) { + appendLobbySystemChat('— ต้องสืบให้ครบทั้ง 3 ผู้ต้องสงสัยก่อน จึงจะชี้ตัวคนร้ายได้'); + return; + } + // เข้าสู่ขั้นพิจารณาคดี/โหวต (ไม่ใช่เริ่มมินิเกม) + socket.emit('suspect-accuse-open', {}, function (res) { + if (!(res && res.ok)) appendLobbySystemChat('— เปิดพิจารณาคดีไม่สำเร็จ' + (res && res.error ? (' · ' + res.error) : '')); + }); + }); + + document.getElementById('suspect-pick-close')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen) return; + closeSuspectOverlay(); + }); + + document.getElementById('btn-suspect-reopen')?.addEventListener('click', () => { + if (!serverSuspectPhaseActive || suspectPickOverlayOpen) return; + openSuspectOverlay(suspectSelectedIndex); + }); + + function applyRoomLobbyBTransition(data) { + const mid = (data && data.mapId) ? String(data.mapId).trim() : ''; + if (!mid) return; + fetch(SERVER + '/api/maps/' + encodeURIComponent(mid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (!json) { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + return; + } + serverSuspectPhaseActive = false; + clientLobbyMapId = mid; + mapData = json; + closeSuspectOverlay(); + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // รีเซ็ตจุดแต่งตัวก่อน แล้วคำนวณใหม่ตาม map ใหม่ (LobbyB ไม่มี customizeSpot → ไม่แสดง icon) + markRoomCzInteractiveCell(); + (data.peersSnap || []).forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + lobbyPath = []; + try { + window.__detectiveLobbyMeta = { + level: data.lobbyLevel || null, + caseId: data.caseId || null + }; + } catch (e) { /* ignore */ } + troublesomeEligibleSent = false; + resizeAndDraw(); + updateHostStartGameButton(); + renderPeers(); + ensureReadyControlEnabled(); + if (data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + updatePlayersHud(); + try { + if (String(mid) === POST_CASE_LOBBY_SPACE_ID) { + var u = new URL(window.location.href); + u.searchParams.set('map', POST_CASE_LOBBY_SPACE_ID); + history.replaceState({}, '', u.pathname + u.search); + } + } catch (e3) { /* ignore */ } + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่ LobbyB ไม่สำเร็จ'); + }); + } + + function focusLobbyMapCanvas() { + try { + if (canvas && typeof canvas.focus === 'function') canvas.focus({ preventScroll: true }); + } catch (e) { /* ignore */ } + } + + function applyLobbyPeersSnapFromServer(rows) { + if (!rows || !rows.length) return; + rows.forEach(function (row) { + if (!row || !row.id) return; + const p = peers.get(row.id); + if (!p) return; + p.x = row.x; + p.y = row.y; + p.direction = row.direction || 'down'; + if (row.nickname) p.nickname = row.nickname; + p.ready = !!row.ready; + if (row.characterId != null) p.characterId = row.characterId; + }); + const meAfter = peers.get(socket.id); + if (readyCheck && meAfter) { + readyCheck.checked = !!meAfter.ready; + updateReadyLabelVisual(); + } + } + + function applyQuizGameStartInLobby(data) { + quizModeActive = true; + quizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + quizPeersLocked = {}; + lastQuizQuestionText = ''; + lastQuizScores = {}; + try { document.body.classList.add('room-lobby--quiz-active'); } catch (e) { /* ignore */ } + showQuizOverlay(); + initQuizScoreboardZeros(); + var qWait = document.getElementById('quiz-game-question'); + if (qWait) qWait.textContent = 'กำลังโหลดคำถาม…'; + var phaseWait = document.getElementById('quiz-game-phase-label'); + if (phaseWait) phaseWait.textContent = 'เกมตอบคำถาม'; + appendLobbySystemChat('— เริ่มเกมตอบคำถาม (เวลาอ่าน/ตอบตั้งใน Admin → คำถามเกม)'); + focusLobbyMapCanvas(); + } + + socket.on('game-start', (data) => { + if (data && data.detectiveReturn && data.stayInRoomLobby) { + applyDetectiveReturnToLobbyB(data); + return; + } + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + if (data && data.cardMinigames) setSuspectCardMinigames(data.cardMinigames); + if (data && Array.isArray(data.suspectProgress)) setSuspectProgress(data.suspectProgress); + if (data && data.quizMode && data.stayInRoomLobby) { + const qMid = (data.mapId != null) ? String(data.mapId).trim() : ''; + applyLobbyPeersSnapFromServer(data.peersSnap); + lobbyPath = []; + applyQuizGameStartInLobby(data); + if (qMid) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(qMid)) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (json) { + if (json) { + mapData = json; + clientLobbyMapId = qMid; + if (!mapData.interactive) mapData.interactive = []; + if (!mapData.startGameArea) mapData.startGameArea = []; + if (!mapData.quizTrueArea) mapData.quizTrueArea = []; + if (!mapData.quizFalseArea) mapData.quizFalseArea = []; + if (!mapData.quizQuestionArea) mapData.quizQuestionArea = []; + ROOM_CZ_SPOT = null; // คำนวณจุดแต่งตัวใหม่ตาม map เกมที่โหลด + markRoomCzInteractiveCell(); + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.onload = function () { resizeAndDraw(); }; + mapBackgroundImg.src = mapData.backgroundImage; + } else { + mapBackgroundImg = null; + } + syncLobbyBUiChrome(); + try { + var u = new URL(window.location.href); + u.searchParams.set('map', qMid); + history.replaceState({}, '', u.pathname + u.search); + } catch (e2) { /* ignore */ } + } else { + appendLobbySystemChat('— โหลดไฟล์แผนที่เกมไม่สำเร็จ — รีเฟรชหรือเช็คว่ามีฉาก ' + qMid + ' บนเซิร์ฟ'); + } + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }) + .catch(function () { + appendLobbySystemChat('— โหลดแผนที่เกมล้มเหลว'); + resizeAndDraw(); + renderPeers(); + redrawLobbyMap(); + }); + return; + } + return; + } + const mid = (data && data.mapId != null) ? String(data.mapId).trim() : ''; + const lobbyLevelStr = (data && data.lobbyLevel != null) ? String(data.lobbyLevel) : ''; + const caseIdStr = (data && data.caseId != null) ? String(data.caseId) : ''; + const isLobbyBMap = mid === POST_CASE_LOBBY_SPACE_ID; + const detectiveMeta = !!(lobbyLevelStr || caseIdStr); + /* LobbyB = แผนที่ mn8nx46h — ต้องอยู่ room-lobby เสมอ ห้ามไป play.html (จะโดน joinLocked แล้วเด้ง lobby) */ + if (data && (data.stayInRoomLobby || isLobbyBMap || (detectiveMeta && !mid && isCurrentRoomLobbyA()))) { + applyRoomLobbyBTransition({ + mapId: mid || POST_CASE_LOBBY_SPACE_ID, + lobbyLevel: data.lobbyLevel != null ? data.lobbyLevel : lobbyLevelStr, + caseId: data.caseId != null ? data.caseId : caseIdStr, + peersSnap: (data && data.peersSnap) ? data.peersSnap : [] + }); + return; + } + if (data && data.detectiveMinigame) { + try { sessionStorage.setItem('detectiveMinigameReturn', '1'); } catch (e) { /* ignore */ } + } + let q = 'play.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + if (mid) q += '&map=' + encodeURIComponent(mid); + if (lobbyLevelStr) q += '&lobbyLevel=' + encodeURIComponent(lobbyLevelStr); + if (caseIdStr) q += '&case=' + encodeURIComponent(caseIdStr); + if (data && data.detectiveMinigame) q += '&detectiveReturn=1'; + location.href = q; + }); + + document.addEventListener('keydown', (e) => { + if (moveCodes.includes(e.code) && !isChatFocused()) { + if (suspectPickOverlayOpen) { e.preventDefault(); return; } + keys[e.code] = true; + e.preventDefault(); + } + if (e.code === 'Escape' && !e.repeat && !isChatFocused()) { + const hostConsoleOv = document.getElementById('host-console-overlay'); + if (hostConsoleOv && !hostConsoleOv.classList.contains('is-hidden')) { + hostConsoleOverlay.close(); + e.preventDefault(); + return; + } + const profileOv = document.getElementById('room-lobby-profile-overlay'); + if (profileOv && !profileOv.classList.contains('is-hidden')) { + roomLobbyProfileOverlay.close(); + e.preventDefault(); + return; + } + const evOv = document.getElementById('lobby-evidence-overlay'); + if (evOv && !evOv.classList.contains('is-hidden')) { + closeLobbyEvidenceModal(); + e.preventDefault(); + return; + } + const rankOv = document.getElementById('lobby-rank-overlay'); + if (rankOv && !rankOv.classList.contains('is-hidden')) { + closeLobbyRankModal(); + e.preventDefault(); + return; + } + if (suspectPickOverlayOpen) { + closeSuspectOverlay(); + e.preventDefault(); + return; + } + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + if (preplayCaseDetailOverlay && !preplayCaseDetailOverlay.classList.contains('is-hidden')) { + preplayCaseDetailOverlay.classList.add('is-hidden'); + } else { + closeLobbyPreplayWizard(); + } + e.preventDefault(); + return; + } + } + if (isLobbyInteractKeyDown(e) && !e.repeat && !isChatFocused()) { + if (suspectPickOverlayOpen) return; + getPreplayEls(); + if (preplayOverlay && !preplayOverlay.classList.contains('is-hidden')) { + return; + } + const me = peers.get(socket.id); + if (!mapData || !me) return; + e.preventDefault(); + /* ทุก F ในโถง — ต้องกดพร้อมก่อน (รวม Host เลือกระดับ/คดี และช่องเขียว) */ + const target = getLobbyInteractTarget(me); + if (isRoomCzInteractTarget(target) || isNearRoomCzSpot(me)) { + e.preventDefault(); + openRoomCustomize(); + return; + } + /* F อื่นในโถง (Host เลือกระดับ/คดี และช่องเขียว) — ต้องกดพร้อมก่อน */ + if (!me.ready) { + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด F'); + return; + } + if (hostCanOpenLobbyAPreplayWithF()) { + openLobbyPreplayWizard(); + return; + } + if (!target) { + if (isCurrentRoomLobbyA() && hostId !== socket.id) { + appendLobbySystemChat('— เฉพาะ Host กด F เพื่อเลือกระดับและคดี'); + } + return; + } + e.preventDefault(); + socket.emit('lobby-interact', { x: target.x, y: target.y }, (res) => { + if (res && res.ok) return; + if (res && res.error) appendLobbySystemChat('— ' + res.error); + }); + } + }); + document.addEventListener('keyup', (e) => { + if (moveCodes.includes(e.code)) keys[e.code] = false; + }); + + var lobbyZoomPctEl = document.getElementById('lobby-zoom-pct'); + var lobbyZoomPctHideTimer = null; + function showZoomPct() { + if (!lobbyZoomPctEl) return; + var pct = Math.round(lobbyZoom * 100); + lobbyZoomPctEl.textContent = pct + '%'; + lobbyZoomPctEl.classList.add('lobby-zoom-pct-visible'); + if (lobbyZoomPctHideTimer) clearTimeout(lobbyZoomPctHideTimer); + lobbyZoomPctHideTimer = setTimeout(function () { + lobbyZoomPctEl.classList.remove('lobby-zoom-pct-visible'); + lobbyZoomPctHideTimer = null; + }, 1500); + } + if (canvas) { + canvas.addEventListener('pointerdown', () => { focusLobbyMapCanvas(); }); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + lobbyZoom *= e.deltaY > 0 ? 0.9 : 1.1; + lobbyZoom = Math.max(LOBBY_ZOOM_MIN, Math.min(LOBBY_ZOOM_MAX, lobbyZoom)); + redrawLobbyMap(); + showZoomPct(); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData) return; + const me = peers.get(socket.id); + if (!me) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const { cw, ch, lobbyZoom: z, tileSize: t, meX, meY, w, h } = mapTransform; + const gx = (sx - cw / 2) / (z * t) + meX; + const gy = (sy - ch / 2) / (z * t) + meY; + const tx = Math.floor(gx); + const ty = Math.floor(gy); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return; + if (!canWalkLobby(tx + 0.5, ty + 0.5)) return; + const targX = tx + 0.5, targY = ty + 0.5; + const path = pathfindLobby(me.x, me.y, targX, targY); + if (path.length <= 1) return; + lobbyPath = path.slice(1); + }); + } + + if (readyCheck) { + ensureReadyControlEnabled(); + updateReadyLabelVisual(); + readyCheck.addEventListener('change', () => { + const ready = readyCheck.checked; + const me = peers.get(socket.id); + if (me) me.ready = ready; + updateReadyLabelVisual(); + renderPeers(); + redrawLobbyMap(); + socket.emit('set-ready', { ready }); + requestAnimationFrame(focusLobbyMapCanvas); + }); + } + + if (btnStart) { + btnStart.addEventListener('click', () => { + if (isCurrentRoomLobbyA()) return; + const mapId = (playMapSelect && playMapSelect.value) ? playMapSelect.value.trim() : ''; + socket.emit('start-game', { mapId: mapId || undefined }, (res) => { + if (res && res.ok === false && res.error) { + try { alert(res.error); } catch (e) { /* ignore */ } + } + }); + }); + } + + const btnLeaveLobby = document.getElementById('btn-leave-lobby'); + if (btnLeaveLobby) { + btnLeaveLobby.addEventListener('click', () => { + window.location.href = CREATE_ROOM_URL; + }); + } + + window.addEventListener('resize', resizeAndDraw); + const wrapEl = document.getElementById('lobby-map-wrap'); + if (wrapEl && typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resizeAndDraw()).observe(wrapEl); + } + resizeAndDraw(); + setTimeout(resizeAndDraw, 100); + updateLobbyProfileAvatar(); + setupRoomCustomize(); + rlEnsureManifest(function () { + rlResolveMyColors(function () { + updateRoomProfileAvatarTinted(); + preloadMyTintedCharacter(function () { roomPreloadReady = true; maybeHideRoomLoading(); }); + }); + }); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + rlResolveMyColors(); + }); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') syncLobbyAvatarFromStorage(); + }); + window.addEventListener('storage', function (e) { + if (e.key == null || e.key === 'gameCharacterId') syncLobbyAvatarFromStorage(); + }); +})(); diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 857302a..bfe5f26 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -1665,6 +1665,13 @@ function assignDetectiveSuspectCardMinigames(space) { gameType: (md && md.gameType) || '', }; }); + // ชุดการ์ดใหม่ = เริ่มนับหลักฐาน (ติกถูก) ใหม่ทั้งหมด + space.suspectProgress = [0, 0, 0]; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; } /** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ @@ -1676,6 +1683,10 @@ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { space.suspectPhaseActive = false; space.detectiveMinigameActive = true; space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; @@ -1768,6 +1779,13 @@ function returnDetectiveSpaceToLobbyB(sid, space, message) { space.gauntletReturnMapId = null; stopGauntletTicker(space); space.gauntletRun = null; + // เล่นมินิเกมจบ → เติมหลักฐาน (ติกถูก) 1 ช่องให้ผู้ต้องสงสัยที่กำลังสืบ (สะสมได้สูงสุด 3) + if (!Array.isArray(space.suspectProgress) || space.suspectProgress.length !== 3) space.suspectProgress = [0, 0, 0]; + const _ai = (typeof space.suspectActiveIndex === 'number') ? space.suspectActiveIndex : -1; + if (_ai >= 0 && _ai <= 2) { + space.suspectProgress[_ai] = Math.min(3, (space.suspectProgress[_ai] || 0) + 1); + } + space.suspectActiveIndex = null; const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); if (!lb) { io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); @@ -1795,6 +1813,7 @@ function returnDetectiveSpaceToLobbyB(sid, space, message) { suspectPhaseActive: true, suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], detectiveReturn: true, }; io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — กลับ LobbyB', returnToLobbyB: true }); @@ -1802,6 +1821,104 @@ function returnDetectiveSpaceToLobbyB(sid, space, message) { io.to(sid).emit('game-start', payload); } +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: space.peers ? space.peers.size : 0, + }); +} + +function computeAndEmitTrialResult(sid, space) { + space.trialPhase = 'revealed'; + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + return (p && p.nickname) ? p.nickname : String(id).slice(0, 6); + }); + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + }); +} + +/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ +function buildTestimonyMembers(space) { + const arr = []; + space.peers.forEach((p, id) => { + arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId }); + }); + return arr; +} + +function emitTestimonyOpen(sid, space) { + space.testimonySubmitted = {}; + space.testimonyReadyNext = {}; + space.testimonyRevealed = false; + io.to(sid).emit('testimony-open', { + round: space.testimonyRound, + suspectIndex: space.testimonyRound, + members: buildTestimonyMembers(space), + totalPlayers: space.peers.size, + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); +} + +function emitTestimonyStatus(sid, space) { + io.to(sid).emit('testimony-status', { + round: space.testimonyRound, + submitted: Object.keys(space.testimonySubmitted || {}), + totalPlayers: space.peers.size, + }); +} + +function doTestimonyReveal(sid, space) { + space.testimonyRevealed = true; + io.to(sid).emit('testimony-reveal', { + round: space.testimonyRound, + picks: space.testimonySubmitted || {}, + members: buildTestimonyMembers(space), + }); +} + +function advanceTestimony(sid, space) { + space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; + if (space.testimonyRound >= 3) { + space.testimonyActive = false; + space.trialPhase = 'voting'; + space.trialVotes = {}; + io.to(sid).emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + emitTrialVoteUpdate(sid, space); + } else { + emitTestimonyOpen(sid, space); + } +} + function endQuizGame(sid, space, message) { clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; @@ -4239,6 +4356,7 @@ io.on('connection', (socket) => { suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], botSlotCount: effectiveBotSlotCount(space), joinLocked: !!space.joinLocked, }; @@ -4326,6 +4444,7 @@ io.on('connection', (socket) => { selectedIndex: space.suspectPickIndex, disruptorAccepted: accept, cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], }); socket.emit('troublesome-ack', { ok: true, accept }); }); @@ -4384,6 +4503,99 @@ io.on('connection', (socket) => { reply({ ok: true }); }); + // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) + socket.on('suspect-accuse-open', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); + const prog = Array.isArray(space.suspectProgress) ? space.suspectProgress : [0, 0, 0]; + if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน' }); + if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); + space.suspectPhaseActive = false; + // เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต + space.testimonyActive = true; + space.testimonyRound = 0; + emitTestimonyOpen(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นส่งหลักฐาน 2 ใบในปากคำปัจจุบัน + socket.on('testimony-submit', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' }); + if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' }); + const raw = (data && Array.isArray(data.picks)) ? data.picks : []; + const picks = []; + raw.forEach((n) => { const v = Math.floor(Number(n)); if (v >= 0 && v <= 8 && picks.indexOf(v) === -1) picks.push(v); }); + if (picks.length !== 2) return reply({ ok: false, error: 'ต้องเลือกหลักฐาน 2 ใบ' }); + if (!space.testimonySubmitted) space.testimonySubmitted = {}; + space.testimonySubmitted[socket.id] = picks.slice(0, 2); + emitTestimonyStatus(sid, space); + if (Object.keys(space.testimonySubmitted).length >= space.peers.size && space.peers.size > 0) doTestimonyReveal(sid, space); + reply({ ok: true }); + }); + + // Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง) + socket.on('testimony-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' }); + doTestimonyReveal(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) + socket.on('testimony-ready-next', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' }); + if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; + space.testimonyReadyNext[socket.id] = true; + io.to(sid).emit('testimony-ready-update', { ready: Object.keys(space.testimonyReadyNext), totalPlayers: space.peers.size }); + if (Object.keys(space.testimonyReadyNext).length >= space.peers.size && space.peers.size > 0) advanceTestimony(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นโหวตชี้ตัวคนร้าย + socket.on('trial-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); + space.trialVotes = space.trialVotes || {}; + space.trialVotes[socket.id] = idx; + emitTrialVoteUpdate(sid, space); + if (Object.keys(space.trialVotes).length >= space.peers.size && space.peers.size > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ socket.on('set-ready', ({ ready }) => { for (const [sid, space] of spaces) { @@ -4480,6 +4692,7 @@ io.on('connection', (socket) => { caseId, peersSnap, cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], }); return reply({ ok: true }); } diff --git a/www/html/Game/server.js.bak-20260521-221647 b/www/html/Game/server.js.bak-20260521-221647 new file mode 100644 index 0000000..857302a --- /dev/null +++ b/www/html/Game/server.js.bak-20260521-221647 @@ -0,0 +1,4950 @@ +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { Server } = require('socket.io'); + +const PORT = process.env.PORT || 3001; +const BASE_PATH = '/Game'; +const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json'); +const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin'; +const adminTokens = new Set(); +/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */ +const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; +/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */ +const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; +/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */ +/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */ +const DETECTIVE_MINIGAME_POOL = [ + { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' }, + { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' }, + { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' }, + { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' }, + { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' }, + { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' }, + { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' }, +]; +const LOBBY_SLOT_TOTAL = 6; + +/** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */ +function effectiveBotSlotCount(space) { + let n = parseInt(space && space.botSlotCount, 10); + if (isNaN(n) || n < 0) n = 0; + const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0; + if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) { + n = LOBBY_SLOT_TOTAL - maxP; + if (space) space.botSlotCount = n; + } + return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n)); +} +/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */ +const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; +/** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */ +const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; +/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */ +const SPACE_EMPTY_TTL_MS = 45000; +// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025) +const AI_MODELS = [ + 'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano', + 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', + 'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research', + 'o1', 'o1-mini', 'o1-pro', + 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', + 'gpt-3.5-turbo', +]; + +function loadAiSettings() { + try { + if (fs.existsSync(AI_SETTINGS_PATH)) { + const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8'); + return JSON.parse(raw); + } + } catch (e) { console.error('loadAiSettings', e.message); } + return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' }; +} + +function saveAiSettings(settings) { + try { + const dir = path.dirname(AI_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch (e) { console.error('saveAiSettings', e.message); } +} + +function getAdminToken(cookieHeader) { + if (!cookieHeader) return null; + const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/); + return m && adminTokens.has(m[1]) ? m[1] : null; +} + +const MAPS_DIR = path.join(__dirname, 'data', 'maps'); +const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters'); +/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */ +const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; +const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right']; + +/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */ +function buildCharacterLayerManifest(id, files) { + if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} }; + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|'); + const byDir = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiHits = files.filter((f) => multiRe.test(f)); + if (multiHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiHits.forEach((f) => { + const m = f.match(multiRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDir[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return; + const m = f.match(singleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] }; + } + } + const byDirIdle = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiIdleHits = files.filter((f) => multiIdleRe.test(f)); + if (multiIdleHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiIdleHits.forEach((f) => { + const m = f.match(multiIdleRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDirIdle[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return; + const m = f.match(singleIdleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] }; + } + } + return { ok: true, id, byDir, byDirIdle }; +} + +/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */ +const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets'); +const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets'); +const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); +/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */ +const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets'); +const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json'); +/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */ +const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH + ? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim()) + : ''; +const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json'); +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 1–16 */ + carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(), + /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */ + carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(), + /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */ + carryChoicePlaqueMapScale: 1.25, + /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */ + carryWalkSpeedMultForMapId: '', + carryWalkSpeedMult: null, + /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */ + quizRoundQuestionCount: 10, + questions: [], + carryQuestions: [], + battleQuizMcq: [], + }; +} + +function defaultCarryMapPanelTheme() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + questionFontMinPx: 10, + questionFontMaxPx: 24, + }; +} + +function sanitizeCssColorToken(input, fallback) { + const t = String(input == null ? '' : input).trim().slice(0, 120); + if (!t) return fallback; + if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback; + 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)) { + const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, ''); + if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim(); + } + return fallback; +} + +function sanitizeCarryMapPanelTheme(raw) { + const d = defaultCarryMapPanelTheme(); + if (!raw || typeof raw !== 'object') return d; + let qMin = Number(raw.questionFontMinPx); + let qMax = Number(raw.questionFontMaxPx); + if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = d.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 t = qMin; + qMin = qMax; + qMax = t; + } + return { + panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg), + panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder), + borderWidthPx: (() => { + let w = Number(raw.borderWidthPx); + if (!Number.isFinite(w)) w = d.borderWidthPx; + return Math.max(0, Math.min(12, Math.round(w))); + })(), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; +} + +/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */ +function defaultCarryChoicePlaqueTheme() { + 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, + }; +} + +function sanitizeCarryChoicePlaqueTheme(raw) { + const d = defaultCarryChoicePlaqueTheme(); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({}); + 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: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder), + fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + borderWidthPx: bw, + plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl), + }; +} + +const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16; + +function defaultCarryChoicePlaqueThemes() { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme({})); + } + return out; +} + +/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */ +function sanitizeCarryChoicePlaqueThemes(raw) { + if (Array.isArray(raw) && raw.length > 0) { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {})); + } + return out; + } + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const one = sanitizeCarryChoicePlaqueTheme(raw); + return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one })); + } + return defaultCarryChoicePlaqueThemes(); +} + +function sanitizeCarryChoiceImageUrl(input) { + const s = String(input == null ? '' : input).trim().slice(0, 512); + if (!s) return ''; + if (/[\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 u = new URL(s); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + return s; + } catch (e) { + return ''; + } +} + +function defaultCarryEmbedCountdownTheme() { + 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 sanitizeCarryEmbedCountdownTheme(raw) { + const d = defaultCarryEmbedCountdownTheme(); + 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))); + }; + return { + overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop), + innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg), + innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder), + innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), + innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), + digitColor: sanitizeCssColorToken(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), + }; +} + +/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */ +const QUIZ_BATTLE_CATEGORY_IDS = new Set([ + 'cybercrime', + 'cyber_law', + 'privacy', + 'digital_security', + 'social_media', + 'ethics', + 'ai_data', +]); + +/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */ +function sanitizeBattleQuizMcq(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + let categoryId = String(q.categoryId || '').trim(); + if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) { + categoryId = 'cybercrime'; + } + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + const rawChoices = Array.isArray(q.choices) ? q.choices : []; + const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160)); + if (choices.length !== 3 || !choices.every(Boolean)) continue; + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(2, Math.floor(ci))); + out.push({ categoryId, text, choices, correctIndex: ci }); + if (out.length >= 200) break; + } + return out; +} + +/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */ +function sanitizeCarryQuestions(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + let choices = Array.isArray(q.choices) + ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean) + : []; + if (choices.length < 2) continue; + if (choices.length > 16) choices = choices.slice(0, 16); + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci))); + const row = { text, choices, correctIndex: ci }; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx])); + if (urls.some((u) => u)) row.choiceImageUrls = urls; + } + out.push(row); + if (out.length >= 80) break; + } + return out; +} + +function clampQuizMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(1000, Math.min(300000, Math.floor(v))); +} + +function clampQuizBetweenMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(300000, Math.floor(v))); +} + +function clampCarrySessionLength(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); +} + +/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */ +function clampQuizRoundQuestionCount(n, def) { + const v = Number(n); + if (!Number.isFinite(v)) { + const d = Number(def); + if (!Number.isFinite(d)) return 10; + return Math.max(1, Math.min(50, Math.floor(d))); + } + return Math.max(1, Math.min(50, Math.floor(v))); +} + +function clampCarryChoicePlaqueMapScale(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100; +} + +/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */ +function sanitizeCarryWalkSpeedMultForMapId(raw) { + const t = String(raw == null ? '' : raw).trim().slice(0, 64); + if (!t) return ''; + if (!/^[a-zA-Z0-9_-]+$/.test(t)) return ''; + return t; +} + +/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */ +function clampCarryWalkSpeedMultSetting(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v)) return null; + return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; +} + +function mergeQuizCarryFromMirrorIfSet(base) { + if (!QUIZ_SETTINGS_MIRROR_PATH) return base; + try { + if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base; + const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8'); + const j = JSON.parse(raw); + if (!j || typeof j !== 'object') return base; + if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') { + base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + } + if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') { + base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + } + if (j.carryReadMs != null) { + base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs); + } + if (j.carryAnswerMs != null) { + base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs); + } + if (j.carrySessionLength != null) { + base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength); + } + if (j.carryWalkSpeedMultForMapId != null) { + base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + } + if (j.carryWalkSpeedMult != null) { + const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (wm != null) base.carryWalkSpeedMult = wm; + } + if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') { + base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + } + if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } + if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) { + base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + } + if (j.quizRoundQuestionCount != null) { + base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount); + } + return base; + } catch (e) { + console.error('mergeQuizCarryFromMirrorIfSet', e.message); + return base; + } +} + +function loadQuizSettings() { + try { + if (fs.existsSync(QUIZ_SETTINGS_PATH)) { + const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8')); + const d = defaultQuizSettings(); + const questions = Array.isArray(j.questions) ? j.questions : []; + const carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq); + const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length + ? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes) + : sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale); + const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null; + else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + const out = { + readMs: clampQuizMs(j.readMs, d.readMs), + answerMs: clampQuizMs(j.answerMs, d.answerMs), + betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs), + carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs), + carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs), + carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength), + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount), + questions, + carryQuestions, + battleQuizMcq, + }; + return mergeQuizCarryFromMirrorIfSet(out); + } + } catch (e) { console.error('loadQuizSettings', e.message); } + return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings()); +} + +const GAUNTLET_DEFAULT_TICK_MS = 220; +const GAUNTLET_DEFAULT_JUMP_TICKS = 16; +const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0; +/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */ +const STACK_SWING_HZ_DEFAULT = 0.55; + +function defaultGameTiming() { + return { + gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS, + gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS, + gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC, + ...defaultGauntletVisuals(), + stackSwingHz: STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: null, + /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */ + jumpSurviveJumpHeightMult: 1.5, + /** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */ + jumpSurviveMissionTimeSec: 0, + /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ + jumpSurvivePlatformTiles: [ + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + ], + /** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */ + stackTowerMissionTimeSec: 90, + /** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */ + stackTeamMissesMax: 3, + /** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ + stackTowerProgressBlocks: 50, + stackBlockNormalImageUrls: ['', '', '', '', '', ''], + stackBlockHeavyImageUrls: ['', '', '', '', '', ''], + /** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */ + stackHeavyBlockPercent: 35, + /** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */ + spaceShooterMissionTimeSec: 0, + spaceShooterShipImageUrls: ['', '', '', '', '', ''], + spaceShooterAsteroidSpriteUrls: [], + spaceShooterAsteroidExplodeFrameMs: 70, + /** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ + spaceShooterAsteroidIntervalMs: 1040, + spaceShooterShipDamageOverlayUrls: ['', '', ''], + /** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */ + balloonBossMissionTimeSec: 0, + balloonBossBossImageUrl: '', + balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''], + /** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */ + balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png', + }; +} + +function clampStackSwingHz(n) { + const v = Number(n); + if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT; + return Math.max(0.08, Math.min(2.8, v)); +} + +/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */ +function clampStackBlockWidthTiles(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return null; + return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100; +} + +function clampStackTowerMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 90; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function clampStackTeamMissesMax(n) { + const v = Number(n); + if (Number.isNaN(v)) return 3; + return Math.max(1, Math.min(20, Math.floor(v))); +} + +function clampStackTowerProgressBlocks(n) { + const v = Number(n); + if (Number.isNaN(v)) return 50; + return Math.max(1, Math.min(500, Math.floor(v))); +} + +/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */ +function normalizeStackBlockSixImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +function clampStackHeavyBlockPercent(n) { + const v = Number(n); + if (Number.isNaN(v)) return 35; + return Math.max(0, Math.min(100, Math.floor(v))); +} + +function clampJumpSurviveJumpHeightMult(n) { + const v = Number(n); + if (Number.isNaN(v)) return 1.5; + return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100; +} + +/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 10–7200 */ +function clampJumpSurviveMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */ +function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = []; + } + } + if (!Array.isArray(arr)) arr = []; + const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : ''; + const out = []; + for (let i = 0; i < 3; i++) { + const o = arr[i]; + const obj = o && typeof o === 'object' ? o : {}; + let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : ''); + if (i === 0 && !url && legacy) url = legacy; + let ww = Number(obj.w); + let hh = Number(obj.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))); + out.push({ url, w: ww, h: hh }); + } + return out; +} + +function clampSpaceShooterMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */ +function normalizeSpaceShooterShipImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */ +function normalizeSpaceShooterAsteroidSpriteUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < arr.length && out.length < 32; i++) { + const u = sanitizeGauntletAssetUrl(arr[i]); + if (u) out.push(u); + } + return out; +} + +function clampSpaceShooterAsteroidExplodeFrameMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return 70; + return Math.max(30, Math.min(500, Math.round(v))); +} + +const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040; +function clampSpaceShooterAsteroidIntervalMs(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + return Math.max(200, Math.min(10000, Math.floor(v))); +} + +/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */ +function normalizeSpaceShooterShipDamageOverlayUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = ['', '', '']; + for (let i = 0; i < 3; i++) { + out[i] = sanitizeGauntletAssetUrl(arr[i]); + } + return out; +} + +function clampGauntletTickMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS; + return Math.max(80, Math.min(800, Math.floor(v))); +} + +function clampGauntletJumpTicks(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS; + return Math.max(4, Math.min(40, Math.floor(v))); +} + +/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */ +function clampGauntletTimeLimitSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function defaultGauntletVisuals() { + return { + gauntletLaneImageUrls: [], + gauntletLaserTopUrl: '', + gauntletLaserBottomUrl: '', + gauntletLaserLineUrl: '', + gauntletLaserFillColor: 'rgba(140,230,255,0.42)', + gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)', + gauntletLaserLineWidthPx: 2, + }; +} + +/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */ +function encodeSpacesInAssetUrlPath(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; +} + +/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */ +function normalizeGameAssetUrlForWeb(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; +} + +/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */ +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; +} + +/** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */ +function fixBareMegaVirusArtboardUrl(t) { + const s = String(t || '').trim().replace(/\/+$/, ''); + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + return t; +} + +function sanitizeGauntletAssetUrl(s) { + const t = fixBareMegaVirusArtboardUrl( + normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '') + ); + if (!t || t.length > 500) return ''; + /** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */ + if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return ''; + /** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */ + if (/\.{4,}/.test(t)) return ''; + if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t); + if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t); + /** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */ + if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`); + return ''; +} + +function clampGauntletLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t) return def; + if (/[<>"'`]/.test(t)) return def; + return t; +} + +function clampGauntletLaserLineWidthPx(n) { + const v = Number(n); + if (Number.isNaN(v)) return 2; + return Math.max(0, Math.min(24, Math.round(v))); +} + +function normalizeGauntletVisualsFromRequest(d) { + const def = defaultGauntletVisuals(); + if (!d || typeof d !== 'object') return { ...def }; + let laneArr = d.gauntletLaneImageUrls; + if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/); + if (!Array.isArray(laneArr)) laneArr = []; + const gauntletLaneImageUrls = []; + for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) { + const u = sanitizeGauntletAssetUrl(laneArr[i]); + if (u) gauntletLaneImageUrls.push(u); + } + return { + gauntletLaneImageUrls, + gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl), + gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl), + gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl), + gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor), + gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor), + gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx), + }; +} + +function getGauntletVisualsForClient() { + const r = runtimeGameTiming; + const d = defaultGauntletVisuals(); + return { + gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls, + gauntletLaserTopUrl: r.gauntletLaserTopUrl || '', + gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '', + gauntletLaserLineUrl: r.gauntletLaserLineUrl || '', + gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor, + gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor, + gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx, + }; +} + +function loadGameTiming() { + try { + if (fs.existsSync(GAME_TIMING_PATH)) { + const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8')); + const vis = normalizeGauntletVisualsFromRequest(j); + return { + gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec), + ...vis, + stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz') + ? clampStackSwingHz(j.stackSwingHz) + : STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(j.stackBlockWidthTiles) + : null, + stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec) + : 90, + stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(j.stackTeamMissesMax) + : 3, + stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks) + : 50, + stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls), + stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls), + stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent) + : 35, + jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult) + : 1.5, + jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec) + : 0, + jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming( + j.jumpSurvivePlatformTiles, + j.jumpSurvivePlatformTileUrl, + ), + spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec) + : 0, + spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls), + spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls), + spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs) + : 70, + spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs) + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT, + spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls), + balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec) + : 0, + balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl) + : '', + balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls), + balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl) + : '', + }; + } + } catch (e) { console.error('loadGameTiming', e.message); } + return defaultGameTiming(); +} + +let runtimeGameTiming = loadGameTiming(); + +function getGauntletTickMs() { + return runtimeGameTiming.gauntletTickMs; +} + +function getGauntletJumpTicks() { + return runtimeGameTiming.gauntletJumpTicks; +} + +function getGauntletTimeLimitSec() { + return runtimeGameTiming.gauntletTimeLimitSec || 0; +} + +function saveGameTimingToFile(d) { + try { + const dir = path.dirname(GAME_TIMING_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const prev = { ...runtimeGameTiming }; + const vis = normalizeGauntletVisualsFromRequest(d); + const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz') + ? clampStackSwingHz(d.stackSwingHz) + : clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT); + const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(d.stackBlockWidthTiles) + : (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles') + ? prev.stackBlockWidthTiles + : null); + const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult') + ? prev.jumpSurviveJumpHeightMult + : 1.5; + const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult) + : clampJumpSurviveJumpHeightMult(prevMult); + const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec') + ? prev.jumpSurviveMissionTimeSec + : 0; + const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec) + : clampJumpSurviveMissionTimeSec(prevJumpMissionSec); + const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming( + prev.jumpSurvivePlatformTiles, + prev.jumpSurvivePlatformTileUrl, + ); + let jumpSurvivePlatformTiles; + if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + d.jumpSurvivePlatformTiles, + d.jumpSurvivePlatformTileUrl, + ); + } else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + [ + { url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 }, + prevJumpTiles[1], + prevJumpTiles[2], + ], + '', + ); + } else { + jumpSurvivePlatformTiles = prevJumpTiles; + } + const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec') + ? prev.spaceShooterMissionTimeSec + : 0; + const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec); + const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls) + ? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls) + : normalizeSpaceShooterShipImageUrls([]); + const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls') + ? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls) + : prevShipUrls; + const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls) + ? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls) + : []; + const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls') + ? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls) + : prevAstUrls; + const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs') + ? prev.spaceShooterAsteroidExplodeFrameMs + : 70; + const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs) + : clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms); + const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs') + ? prev.spaceShooterAsteroidIntervalMs + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs) + : clampSpaceShooterAsteroidIntervalMs(prevAstIv); + const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls) + ? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls) + : normalizeSpaceShooterShipDamageOverlayUrls([]); + const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls') + ? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls) + : prevDmg; + const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec') + ? prev.balloonBossMissionTimeSec + : 0; + const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevBbMission); + const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl) + : ''; + const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl) + : prevBbBoss; + const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls) + ? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls) + : normalizeStackBlockSixImageUrls([]); + const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls') + ? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls) + : prevBbBalloons; + const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl) + : ''; + const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl) + : prevBbFb; + const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec') + ? prev.stackTowerMissionTimeSec + : 90; + const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec) + : clampStackTowerMissionTimeSec(prevStackTowerSec); + const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax') + ? prev.stackTeamMissesMax + : 3; + const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(d.stackTeamMissesMax) + : clampStackTeamMissesMax(prevStackMisses); + const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks') + ? prev.stackTowerProgressBlocks + : 50; + const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks) + : clampStackTowerProgressBlocks(prevStackProgBlocks); + const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls) + : normalizeStackBlockSixImageUrls([]); + const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls) + : normalizeStackBlockSixImageUrls([]); + const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls) + : prevStackNorm; + const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls) + : prevStackHeavy; + const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent) + : 35; + const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent) + : prevHeavyPct; + const out = { + gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec), + ...vis, + stackSwingHz: stackHz, + stackBlockWidthTiles: stackBlockW, + stackTowerMissionTimeSec, + stackTeamMissesMax, + stackTowerProgressBlocks, + stackBlockNormalImageUrls, + stackBlockHeavyImageUrls, + stackHeavyBlockPercent, + jumpSurviveJumpHeightMult: jumpMult, + jumpSurviveMissionTimeSec: jumpMissionSec, + jumpSurvivePlatformTiles, + spaceShooterMissionTimeSec: spaceShooterMissionSec, + spaceShooterShipImageUrls, + spaceShooterAsteroidSpriteUrls, + spaceShooterAsteroidExplodeFrameMs, + spaceShooterAsteroidIntervalMs, + spaceShooterShipDamageOverlayUrls, + balloonBossMissionTimeSec, + balloonBossBossImageUrl, + balloonBossPlayerBalloonImageUrls, + balloonBossPlayerBalloonFallbackUrl, + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + 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 }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(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 serverWallCollisionTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + 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) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(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) => quizCellOn(tGrid, x, y) || quizCellOn(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 validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(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; +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + const shuffled = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + space.suspectCardMinigames = shuffled.slice(0, 3).map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + + let si = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, si++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message) { + ensurePostCaseLobbyMapLoaded(); + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + stopGauntletTicker(space); + space.gauntletRun = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, gi++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const payload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + detectiveReturn: true, + }; + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — กลับ LobbyB', returnToLobbyB: true }); + io.to(sid).emit('detective-minigame-ended', payload); + io.to(sid).emit('game-start', payload); +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + returnDetectiveSpaceToLobbyB(sid, space, message); + space.quizSession = null; + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + if (allWrong) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + scheduleQuizReadPhase(sid, space, md); +} + +function safeMapId(id) { + return (id || '').replace(/[^a-z0-9_-]/gi, '') || null; +} + +/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */ +function normalizeQuizCarryLayersOnMap(m) { + if (!m || m.gameType !== 'quiz_carry') return; + const w = m.width || 20, h = m.height || 15; + const normHub = () => { + const src = m.quizCarryHubArea || []; + 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); + } + m.quizCarryHubArea = rows; + }; + const normOptions = () => { + const src = m.quizCarryOptionArea || []; + 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 <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0); + } + rows.push(row); + } + m.quizCarryOptionArea = rows; + }; + const normCountdown = () => { + const src = m.carryEmbedCountdownArea || []; + 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]; + row.push(Number(v) === 1 ? 1 : 0); + } + rows.push(row); + } + m.carryEmbedCountdownArea = rows; + }; + normHub(); + normOptions(); + normCountdown(); +} + +/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */ +function normalizeQuizBattleDomeAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattleDomeArea || []; + 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); + } + m.quizBattleDomeArea = rows; +} + +function normalizeQuizBattlePathAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattlePathArea || []; + 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); + } + m.quizBattlePathArea = rows; +} + +function quizBattlePathModeActiveServer(m) { + if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false; + const g = m.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 quizBattleFootprintFullyOnPathServer(m, px, py) { + if (!quizBattlePathModeActiveServer(m)) return true; + const g = m.quizBattlePathArea; + for (const k of quizCarryFootprintTileKeys(m, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; +} + +function serverFootprintClearOfWalls(md, px, py) { + const w = md.width || 20, h = md.height || 15; + for (const k of serverWallCollisionTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) return false; + } + return true; +} + +/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */ +function snapPositionOntoQuizBattlePathServer(md, px, py) { + if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py }; + if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py }; + const w = md.width || 20, h = md.height || 15; + const g = md.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 (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue; + if (!serverFootprintClearOfWalls(md, 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 }; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) 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 }; +} + +function normalizeJumpSurvivePlatformAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurvivePlatformArea = rows; +} + +function normalizeJumpSurvivePlatformVariantAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const pa = m.jumpSurvivePlatformArea || []; + const src = m.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); + } + m.jumpSurvivePlatformVariantArea = rows; +} + +function normalizeJumpSurviveHazardAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurviveHazardArea = rows; +} + +/** + * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON + */ +function applyStackTowerMissionMapPlayPolicy(safeId, m) { + if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return; + const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {}; + const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); + const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up'; + const spNum = Number(raw.speedPxPerSec); + const speedPxPerSec = Number.isFinite(spNum) + ? Math.max(0, Math.min(400, Math.floor(spNum))) + : 0; + const sel = Math.floor(Number(raw.stepEveryLayers)); + const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0; + const ssp = Math.floor(Number(raw.stepScrollPx)); + const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0; + const sam = Math.floor(Number(raw.stepAnimMs)); + const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520; + const st = { + enabled: raw.enabled === true, + speedPxPerSec, + scrollDirection, + stepEveryLayers, + stepScrollPx, + stepAnimMs, + }; + if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage; + if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage; + const rg = Number(raw.releaseGapWorldPx); + if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg)); + m.stackTowerBgScroll = st; + const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {}; + const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12)); + const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260)); + m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx }; +} + +function loadMaps() { + try { + if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true }); + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(MAPS_DIR); + for (const f of files) { + if (!f.endsWith('.json')) continue; + const id = f.slice(0, -5); + if (!safeMapId(id)) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(id, data); + maps.set(id, data); + } catch (e) { console.error('load map', f, e.message); } + } + } catch (e) { console.error('loadMaps', e.message); } +} + +/** + * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที + * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า) + */ +function rehydrateSingleMapFromDisk(safeId) { + if (!safeId) return false; + const fp = path.join(MAPS_DIR, safeId + '.json'); + if (!fs.existsSync(fp)) return false; + try { + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(safeId, data); + maps.set(safeId, data); + return true; + } catch (e) { + console.error('rehydrate map', safeId, e.message); + return false; + } +} + +/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */ +function ensurePostCaseLobbyMapLoaded() { + if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true; + try { + const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json'); + if (!fs.existsSync(fp)) return false; + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + maps.set(POST_CASE_LOBBY_SPACE_ID, data); + return true; + } catch (e) { + console.error('ensurePostCaseLobbyMapLoaded', e.message); + return false; + } +} + +function saveMap(id, m) { + const safe = safeMapId(id); + if (!safe) return; + try { + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8'); + } catch (e) { console.error('saveMap', id, e.message); } +} + +function isMapNameTaken(name, excludeId) { + const n = (name || '').trim().toLowerCase(); + for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true; + return false; +} + +const server = http.createServer((req, res) => { + let url = req.url; + if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html'; + if (url.startsWith(BASE_PATH + '/api/maps')) { + let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0]; + if (id.includes('?')) id = id.split('?')[0]; + if (req.method === 'GET' && id) { + const sid = safeMapId(decodeURIComponent(id)); + if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + rehydrateSingleMapFromDisk(sid); + const m = maps.get(sid); + if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m)); + } + if (req.method === 'GET' && !id) { + const list = [...maps].map(([id, m]) => ({ id, name: m.name })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || 'ฉากใหม่'; + if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const id = Date.now().toString(36); + const m = { ...defaultMap(), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (req.method === 'PUT' && id && maps.has(id)) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || maps.get(id).name; + if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const m = { ...maps.get(id), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + applyStackTowerMissionMapPlayPolicy(id, m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + const quizCarryDiskPaths = new Set([ + BASE_PATH + '/api/quiz-carry-from-disk', + BASE_PATH + '/api-quiz-carry-from-disk.php', + ]); + if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) { + if (req.method === 'GET') { + const g = loadQuizSettings(); + const slice = { + carryMapPanelTheme: g.carryMapPanelTheme, + carryEmbedCountdownTheme: g.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: g.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: g.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale, + carryReadMs: g.carryReadMs, + carryAnswerMs: g.carryAnswerMs, + carrySessionLength: g.carrySessionLength, + carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: g.carryWalkSpeedMult, + carryQuestions: g.carryQuestions, + }; + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(slice)); + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(loadQuizSettings())); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveQuizSettings(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + battleQuizMcqSaved: saved.battleQuizMcqSaved, + carryQuestionsSaved: saved.carryQuestionsSaved, + carryMapPanelTheme: saved.carryMapPanelTheme, + carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + }); + return res.end(JSON.stringify({ ...runtimeGameTiming })); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveGameTimingToFile(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + const { ok: _ok, error: _err, ...timingRest } = saved; + return res.end(JSON.stringify({ ok: true, ...timingRest })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() })); + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureGauntletAssetsDir(); + const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : ''; + let fname; + if (replaceFilename) { + const s = safeGauntletStoredFilename(replaceFilename); + if (!s) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' })); + } + const cur = path.join(GAUNTLET_ASSETS_DIR, s); + if (!fs.existsSync(cur)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' })); + } + const norm = (e) => { + let x = String(e || '').toLowerCase(); + if (x.startsWith('.')) x = x.slice(1); + return x === 'jpeg' ? 'jpg' : x; + }; + if (norm(path.extname(s)) !== norm(ext)) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' })); + } + fname = s; + } else { + fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`; + } + fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf); + const meta = loadGauntletAssetsMeta(); + const prev = meta[fname] || {}; + meta[fname] = { + ...prev, + label: labelIn || prev.label || fname, + updatedAt: Date.now(), + }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/gauntlet-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureQuizCarryPlaqueAssetsDir(); + const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`; + fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const fname = safeGauntletStoredFilename(d.filename); + if (!fname) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' })); + } + const meta = loadGauntletAssetsMeta(); + const label = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') { + try { + const q = url.includes('?') ? url.split('?')[1] : ''; + const sp = new URLSearchParams(q); + const rawFn = sp.get('file'); + const fname = safeGauntletStoredFilename(rawFn); + if (!fname) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + const meta = loadGauntletAssetsMeta(); + delete meta[fname]; + saveGauntletAssetsMeta(meta); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' })); + } + } + if (url === BASE_PATH + '/api/characters' && req.method === 'GET') { + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const ids = new Set(); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + files.forEach(f => { + let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + }); + const idArr = [...ids]; + const oldestMsById = {}; + idArr.forEach((id) => { + let oldest = Infinity; + const prefix = id + '_'; + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + try { + const st = fs.statSync(path.join(CHARACTERS_DIR, f)); + const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime(); + if (t < oldest) oldest = t; + } catch (e) { /* ignore */ } + }); + oldestMsById[id] = oldest === Infinity ? 0 : oldest; + }); + idArr.sort((a, b) => { + const ta = oldestMsById[a] || 0; + const tb = oldestMsById[b] || 0; + if (ta !== tb) return ta - tb; + return String(a).localeCompare(String(b)); + }); + const list = idArr.map((id) => { + const prefix = id + '_'; + const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f)); + const layerNameSet = new Set(); + if (hasLayerFiles) { + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) { + layerNameSet.add(m[1]); + return; + } + m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) layerNameSet.add(m[1]); + }); + } + const layers = layerNameSet.size ? [...layerNameSet].sort() : []; + const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null; + return { id, name: id, hasLayerFiles, layers, layerManifest }; + }); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(list)); + } catch (e) { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify([])); + } + } + const urlPath = url.split('?')[0]; + { + const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$')); + if (lm && req.method === 'GET') { + const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const manifest = buildCharacterLayerManifest(id, files); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(manifest)); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} })); + } + } + } + if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') { + if (/\/layer-manifest$/i.test(urlPath)) { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' })); + } + const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length)); + const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + let removed = 0; + files.forEach(f => { + if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) { + try { + fs.unlinkSync(path.join(CHARACTERS_DIR, f)); + removed++; + } catch (e) { + // ignore single file errors + } + } + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, removed })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') })); + } + return; + } + if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + if (!body || body.length < 2) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' })); + } + const d = JSON.parse(body); + const dirs = ['up', 'down', 'left', 'right']; + const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now(); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 }; + let walkWritten = 0; + for (const dir of dirs) { + let data = d[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + // เขียนไฟล์เฟรม (สำหรับ animation) + const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + walkWritten++; + frameIndex++; + } + } + let layerWritten = 0; + // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html) + const lf = d.layerFrames; + if (lf && typeof lf === 'object') { + for (const dir of dirs) { + const arr = lf[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const lfIdle = d.layerFramesIdle; + if (lfIdle && typeof lfIdle === 'object') { + for (const dir of dirs) { + const arr = lfIdle[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_idle_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null; + if (idleObj) { + for (const dir of dirs) { + let data = idleObj[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameFrame = list.length > 1 + ? (id + '_' + dir + '_idle_' + frameIndex + '.png') + : (id + '_' + dir + '_idle.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '_idle.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + frameIndex++; + } + } + } + if (walkWritten === 0 && layerWritten === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' })); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, characterId: id })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/api/spaces')) { + const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean); + const spaceId = parts[0]; + if (req.method === 'GET' && !spaceId) { + const now = Date.now(); + for (const [id, s] of [...spaces.entries()]) { + if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue; + const n = s.peers ? s.peers.size : 0; + if (n !== 0) continue; + const created = s.createdAt; + const stale = created == null || now - created > SPACE_EMPTY_TTL_MS; + if (stale) spaces.delete(id); + } + const list = [...spaces] + .filter(([, s]) => !s.isPrivate) + .filter(([, s]) => (s.peers && s.peers.size > 0)) + .map(([id, s]) => ({ + spaceId: id, + spaceName: s.spaceName || id, + mapName: (s.mapData && s.mapData.name) || '—', + peerCount: s.peers ? s.peers.size : 0, + maxPlayers: s.maxPlayers || 10 + })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'GET' && spaceId) { + const s = spaces.get(spaceId); + if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name })); + } + if (req.method === 'POST' && !spaceId) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body || '{}'); + const mapData = d.mapId ? maps.get(d.mapId) : null; + if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' })); + const isPrivate = !!d.isPrivate; + let sid; + if (isPrivate) { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code; + for (let i = 0; i < 20; i++) { + code = ''; + for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)]; + if (!spaces.has(code)) { sid = code; break; } + } + if (!sid) sid = 'priv-' + Date.now(); + } else { + const base = (d.name || '').trim() || 'space'; + sid = base + '-' + Date.now(); + } + const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid); + let maxPlayers = parseInt(d.maxPlayers, 10); + if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10; + maxPlayers = Math.min(10, Math.max(1, maxPlayers)); + if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6); + const mapId = d.mapId ? String(d.mapId).trim() : null; + const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview'; + let botSlotCount = parseInt(d.botSlotCount, 10); + if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0; + botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers }); + spaces.set(sid, { + mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(), + previewEditorTest, + botSlotCount, + suspectCardMinigames: null, + suspectPhaseActive: false, + suspectPickIndex: 0, + detectiveMinigameActive: false, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') { + function sendIceServers(iceServers) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ iceServers: iceServers || [] })); + } + function fallback() { + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + } + const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY; + const meteredAppId = process.env.METERED_TURN_APP_ID; + let meteredBase = process.env.METERED_TURN_URL; + if (!meteredBase) { + let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret'; + if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live'; + meteredBase = 'https://' + meteredHost; + } + meteredBase = meteredBase.replace(/\/$/, ''); + if (meteredKey) { + function tryNext(credUrl) { + return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) { + if (data && (data.error || data.message)) throw new Error(data.error || data.message); + const list = Array.isArray(data) ? data : (data && data.iceServers); + if (list && list.length) return sendIceServers(list); + if (data && data.data && Array.isArray(data.data) && data.data.length) { + const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca'; + const built = data.data.map(function (c) { + return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password }; + }); + return sendIceServers(built); + } + throw new Error('No iceServers'); + }); + } + const urlsToTry = [ + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey), + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey), + meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey) + ]; + let p = tryNext(urlsToTry[0]); + for (let i = 1; i < urlsToTry.length; i++) { + p = p.catch(function () { return tryNext(urlsToTry[i]); }); + } + p.catch(fallback); + return; + } + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + return; + } + if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const password = (d.password || '').trim(); + if (password !== ADMIN_PASSWORD) { + return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' })); + } + const token = require('crypto').randomBytes(24).toString('hex'); + adminTokens.add(token); + res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax'); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + const s = loadAiSettings(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + model: s.model || 'gpt-4o-mini', + models: AI_MODELS, + hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0), + intent: s.intent != null ? s.intent : '', + rag_enabled: !!s.rag_enabled, + rag_context: s.rag_context != null ? s.rag_context : '', + })); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const s = loadAiSettings(); + if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim(); + if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini'; + if (d.intent !== undefined) s.intent = String(d.intent); + if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled; + if (d.rag_context !== undefined) s.rag_context = String(d.rag_context); + saveAiSettings(s); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const message = (d.message || d.text || '').trim(); + if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' })); + const s = loadAiSettings(); + if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' })); + const model = s.model || 'gpt-4o-mini'; + const messages = []; + const systemParts = []; + if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim()); + if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) { + systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim()); + } + if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') }); + messages.push({ role: 'user', content: message }); + const payload = JSON.stringify({ + model: model, + messages: messages, + max_tokens: 500, + }); + const urlObj = new URL('https://api.openai.com/v1/chat/completions'); + const opts = { + hostname: urlObj.hostname, + path: urlObj.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + s.openai_api_key, + 'Content-Length': Buffer.byteLength(payload, 'utf8'), + }, + }; + const reqOut = require('https').request(opts, (resOut) => { + let data = ''; + resOut.on('data', chunk => data += chunk); + resOut.on('end', () => { + try { + const parsed = JSON.parse(data); + const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content; + if (text != null) { + res.writeHead(200); + res.end(JSON.stringify({ response: String(text).trim() })); + } else { + res.writeHead(502); + res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' })); + } + } catch (e) { + res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' })); + } + }); + }); + reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); }); + reqOut.write(payload); + reqOut.end(); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') { + const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0]; + const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, ''); + if (fname && fname.length > 0) { + const fp = path.join(CHARACTERS_DIR, fname); + if (fs.existsSync(fp)) { + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(data); + }); + } + } + } + if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') { + const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]); + const fname = safeGauntletStoredFilename(sub); + if (!fname) { + res.writeHead(400); + return res.end(); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(); + } + const ext = path.extname(fname); + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) }); + res.end(data); + }); + } + if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html'; + const filePath = path.join(__dirname, 'public', url); + const ext = path.extname(filePath); + const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' }; + fs.readFile(filePath, (err, data) => { + if (err) return res.writeHead(404), res.end('Not Found'); + res.writeHead(200, { + 'Content-Type': types[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + res.end(data); + }); +}); + +const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } }); +const spaces = new Map(); + +const GAUNTLET_MAX_PLAYERS = 6; +/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */ +/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */ +const GAUNTLET_LASER_SPAWN_CHANCE = 0.14; +/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */ +const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34; +/** Last Light / museum gauntlet — scoring, mission UI, how-to flow */ +const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07'; +/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */ +const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; +/** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */ +const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; + +function isGauntletCrownHeistMapBySpace(space) { + return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); +} + +function isBalloonBossMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'balloon_boss'); +} + +/** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */ +function isQuizQuestionMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'quiz'); +} + +function isStackTowerMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'stack'); +} + +function isJumpSurviveMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'jump_survive'); +} + +function isSpaceShooterMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'space_shooter'); +} + +function isCrownLobbyShellSpace(space) { + return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space); +} + +function newBalloonBossShellGauntletRunState() { + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 999, + crownRunHeld: true, + timerId: null, + endsAt: null, + }; +} + +function emitBalloonBossMissionShellGauntletSync(sid, space) { + if (!isBalloonBossMissionShellSpace(space)) return; + const gr = space.gauntletRun; + io.to(sid).emit('gauntlet-sync', { + gauntletCrownRunHeld: !!(gr && gr.crownRunHeld), + }); +} + +function rollGauntletCrownRewardCard(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' }; +} + +function gauntletCrownRankBonus(rankOrdinal) { + if (rankOrdinal === 1) return 100; + if (rankOrdinal === 2) return 80; + if (rankOrdinal === 3) return 60; + return 0; +} + +function buildGauntletCrownMissionPayload(space) { + const peersArr = [...space.peers.values()]; + const rows = peersArr.map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + characterId: p.characterId ?? null, + baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0), + eliminated: !!p.gauntletEliminated, + })); + rows.sort((a, b) => { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th'); + }); + let lastScore = null; + let rankOrdinal = 0; + const ranked = []; + for (let i = 0; i < rows.length; i++) { + const pos = i + 1; + if (lastScore !== rows[i].baseScore) { + rankOrdinal = pos; + lastScore = rows[i].baseScore; + } + const bonus = gauntletCrownRankBonus(rankOrdinal); + const finalScore = rows[i].baseScore + bonus; + const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal); + ranked.push({ + id: rows[i].id, + nickname: rows[i].nickname, + characterId: rows[i].characterId, + baseScore: rows[i].baseScore, + eliminated: rows[i].eliminated, + rank: rankOrdinal, + rankLabel, + rankBonus: bonus, + finalScore, + }); + } + const totalSum = ranked.reduce((s, r) => 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 = rollGauntletCrownRewardCard(grade); + return { + ranked, + totalSum, + averageScore, + grade, + rewardCard, + totalParts: ranked.map((r) => r.finalScore), + }; +} + +function gauntletSpawnYs(md, playerCount) { + const h = md.height || 15; + const lo = 1; + const hi = Math.max(lo, h - 2); + const n = Math.min(GAUNTLET_MAX_PLAYERS, 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; +} + +/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */ +function collectGauntletSpawnSlotsFromSpawnArea(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 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y }); + } + } + slots.sort((a, b) => a.y - b.y || a.x - b.x); + return slots; +} + +/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */ +function collectGauntletSpawnSlotsFromMap(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_MAX_PLAYERS) 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 (!isMapTileWalkableForSpawn(md, x, y)) continue; + slots.push({ x, y }); + } + if (slots.length > 0) return slots; + } + return collectGauntletSpawnSlotsFromSpawnArea(md); +} + +function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) { + const w = md.width || 20; + if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX; + if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback; + for (let d = 0; d < w; d++) { + if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d; + if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d; + } + return Math.max(0, Math.min(w - 1, spawnColX)); +} + +/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */ +function gauntletSpawnPositions(md, playerCount) { + const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount)); + const slots = collectGauntletSpawnSlotsFromMap(md); + const fallbackYs = gauntletSpawnYs(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 = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX); + out.push({ x, y }); + } + return out; +} + +function stopGauntletTicker(space) { + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } +} + +/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */ +function ensureGauntletEndsAtIfNeeded(space) { + const gr = space.gauntletRun; + if (!gr) return; + if (gr.crownRunHeld) return; + const lim = getGauntletTimeLimitSec(); + if (lim > 0 && gr.endsAt == null) { + gr.endsAt = Date.now() + lim * 1000; + } +} + +function newGauntletRunState(space) { + const crown = !!(space && isGauntletCrownHeistMapBySpace(space)); + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 3, + crownRunHeld: crown, + }; +} + +function emitGauntletSync(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + const gr = space.gauntletRun; + if (!gr) return; + const h = md.height || 15; + const obsOut = gr.obstacles.map((o) => { + if (o.kind === 'laser') { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1; + return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 }; + } + return { id: o.id, kind: o.kind, x: o.x, y: o.y }; + }); + const playersOut = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + io.to(sid).emit('gauntlet-sync', { + obstacles: obsOut, + players: playersOut, + gauntletTickMs: getGauntletTickMs(), + gauntletJumpTicks: getGauntletJumpTicks(), + gauntletTimeLimitSec: getGauntletTimeLimitSec(), + gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null, + gauntletCrownRunHeld: !!gr.crownRunHeld, + ...getGauntletVisualsForClient(), + }); +} + +function startGauntletTicker(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.timerId) return; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); +} + +function initGauntletPeersAll(space, md) { + const list = [...space.peers.values()]; + const pos = gauntletSpawnPositions(md, list.length); + const crown = isGauntletCrownHeistMapBySpace(space); + list.forEach((p, i) => { + const pt = pos[i] || pos[pos.length - 1]; + p.x = pt.x; + p.y = pt.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = crown ? 100 : 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + space.gauntletRun = newGauntletRunState(space); + if (crown) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + } + space.gauntletPreviewRowsBySocket = new Map(); +} + +function findFallbackLobbyMapForGauntletEnd() { + for (const [id, m] of maps) { + if (m && m.gameType === 'lobby') return { id, m }; + } + return null; +} + +/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */ +function endGauntletGame(sid, space, reason) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.detectiveMinigameActive) { + const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + 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 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + } + /* ประเภท 1: แยกเลน — แถวเท้า (ไม่ใช่แถวหัว; ไม่ใช่แถวใต้เท้าอีกช่อง) */ + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(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 mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(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: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(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; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(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 (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(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 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 15 }); +} + +function scheduleTroublesomeRoll(sid) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450); + if (!space.troublesomeMaxTimer) { + space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000); + } +} + +/** + * ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์ + * — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview + */ +function spaceAllowsQuizCarryLobbyRelaxed(space) { + if (!space) return false; + if (space.mapId) { + const byId = maps.get(space.mapId); + if (byId && byId.gameType === 'quiz_carry') return true; + } + const md = space.mapData; + if (md && md.gameType === 'quiz_carry') return true; + return !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); +} + +io.on('connection', (socket) => { + socket.on('join-space', ({ spaceId, nickname, characterId, playMapId }, cb) => { + const space = spaces.get(spaceId); + if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' }); + const maxPlayers = space.maxPlayers || 10; + const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' }); + const joinNickNorm = normalizeLobbyNickname(nickname); + if (space.joinLocked) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + if (!space.hostId) space.hostId = socket.id; + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = space.peers.size; + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3)); + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = [...space.peers.keys()].indexOf(socket.id); + const pos = gauntletSpawnPositions(mdJoin, space.peers.size); + const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + botSlotCount: effectiveBotSlotCount(space), + joinLocked: !!space.joinLocked, + }; + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + if (typeof cb === 'function') cb(joinCb); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + }); + + /** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */ + socket.on('preview-claim-host', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const isPreviewEditorRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' }); + space.hostId = socket.id; + io.to(sid).emit('host-changed', { hostId: socket.id }); + return reply({ ok: true }); + }); + + socket.on('troublesome-eligible', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return; + initTroublesomeState(space); + space.troublesomeEligible.add(socket.id); + scheduleTroublesomeRoll(sid); + }); + + socket.on('troublesome-response', (data) => { + const accept = !!(data && data.accept); + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return; + if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return; + if (space.troublesomeResponseReceived) return; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = accept; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + io.to(sid).emit('suspect-phase-open', { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: accept, + cardMinigames: space.suspectCardMinigames || [], + }); + socket.emit('troublesome-ack', { ok: true, accept }); + }); + + socket.on('suspect-pick-select', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return; + if (space.hostId !== socket.id) return; + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return; + space.suspectPickIndex = idx; + io.to(sid).emit('suspect-pick-update', { selectedIndex: idx }); + }); + + socket.on('suspect-pick-start', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (space && Array.isArray(space.suspectCardMinigames)) { + space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry); + } + if (!space || !serverMapIsPostCaseLobbyB(space)) { + return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' }); + } + if (!space.suspectPhaseActive) { + return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' }); + } + if (space.hostId !== socket.id) { + return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) return reply({ ok: true, alreadyOnLobbyB: true }); + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB'); + reply({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.ready = !!ready; + io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); + break; + } + } + }); + + socket.on('start-game', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id) && space.hostId === socket.id) { + let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; + let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; + if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; + if (caseId && !/^[1-3]$/.test(caseId)) caseId = ''; + ensurePostCaseLobbyMapLoaded(); + const mdBefore = getLobbyLayoutMapForSpace(space); + if (mdBefore && mdBefore.gameType === 'quiz') { + const qErr = validateQuizMap(mdBefore); + if (qErr) return reply({ ok: false, error: qErr }); + clearSpaceQuizTimers(space); + startQuizGame(sid, space, mdBefore); + io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); + return reply({ ok: true }); + } + const detectiveB = !!(data && data.detectiveLobbyBStart); + if (detectiveB && (!lobbyLevel || !caseId)) { + return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); + } + const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; + const isNamedLobbyA = mapNameNorm === 'lobbya'; + const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); + /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ + const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) + && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) + && ( + mdBefore.gameType === 'lobby' + || isNamedLobbyA + || (detectiveB && notZepOrFrogger) + )); + if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { + return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); + } + if (!lobbyHostStandingInStartArea(space, socket.id)) { + return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); + } + if (isLobbyADetectiveStart) { + assignDetectiveSuspectCardMinigames(space); + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); + const caseNickWl = new Set(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + let li = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, li++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + io.to(sid).emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + if (space.voiceInits) delete space.voiceInits[socket.id]; + if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id); + if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id]; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id); + if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') { + delete space.quizCarryLobbyReady[socket.id]; + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + } + if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { + delete space.gauntletCrownLobbyReady[socket.id]; + if (isCrownLobbyShellSpace(space)) { + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + } + space.peers.delete(socket.id); + if (space.peers.size === 0) { + stopGauntletTicker(space); + clearSpaceQuizTimers(space); + space.quizSession = null; + /* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */ + const keepCaseSpace = !!(space.detectiveMinigameActive + || (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0)); + if (keepCaseSpace) { + space.hostId = null; + return; + } + spaces.delete(sid); + } else { + if (wasHost) { + const firstPeer = space.peers.values().next().value; + if (firstPeer) { + space.hostId = firstPeer.id; + io.to(sid).emit('host-changed', { hostId: firstPeer.id }); + } + } + io.to(sid).emit('user-left', { id: socket.id }); + } + }); + + socket.on('gauntlet-jump', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return; + if ((p.gauntletJumpTicks || 0) > 0) return; + p.gauntletJumpPending = true; + }); + + socket.on('gauntlet-crown-begin-run', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const gr = space.gauntletRun; + if (!gr) return reply({ ok: false }); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space); + const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space); + if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false }); + const previewRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' }); + if (!gr.crownRunHeld) return reply({ ok: true, already: true }); + gr.crownRunHeld = false; + if (okGauntletCrown) { + ensureGauntletEndsAtIfNeeded(space); + emitGauntletSync(sid, space); + } else { + emitBalloonBossMissionShellGauntletSync(sid, space); + } + return reply({ ok: true }); + }); + + /** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */ + socket.on('gauntlet-preview-rows', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + const hh = md.height || 15; + if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map(); + const raw = data && Array.isArray(data.ys) ? data.ys : []; + const set = new Set(); + const cap = Math.min(raw.length, 48); + for (let i = 0; i < cap; i++) { + const fy = Math.floor(Number(raw[i])); + if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy); + } + space.gauntletPreviewRowsBySocket.set(socket.id, set); + }); + + socket.on('quiz-carry-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') { + space.quizCarryLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + socket.emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + space.quizCarryLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const rm = space.quizCarryLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('quiz-carry-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('gauntlet-crown-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') { + space.gauntletCrownLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + socket.emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const gr = space.gauntletRun; + if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' }); + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('gauntlet-crown-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('move', (data) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (md && md.gameType === 'gauntlet') { + const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId }; + socket.emit('user-move', out); + break; + } + let nx = data.x; + let ny = data.y; + const sess = space.quizSession; + const mdQuiz = (sess && sess.quizMapMd) || md; + if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') { + const st = sess.players && sess.players[socket.id]; + if (st && !st.eliminated) { + const tx = Math.floor(Number(nx)); + const ty = Math.floor(Number(ny)); + const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty); + const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty); + if (blockTrue || blockFalse) { + nx = p.x; + ny = p.y; + } + } + } + if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) { + const txN = Number(nx); + const tyN = Number(ny); + if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + } + if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const txN = Number(nx); + const tyN = Number(ny); + if (!Number.isFinite(txN) || !Number.isFinite(tyN)) { + nx = p.x; + ny = p.y; + } else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny)); + nx = sn.x; + ny = sn.y; + } + if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) { + const ns = Math.floor(Number(data.spaceShooterScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.spaceShooterScore | 0); + if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns); + } + } + if (p && md && md.gameType === 'balloon_boss' && data) { + const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + if (data.balloonBossScore != null) { + const ns = Math.floor(Number(data.balloonBossScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.balloonBossScore | 0); + if (ns <= prev + 35) p.balloonBossScore = Math.max(prev, ns); + } + } + if (data.balloonBossBossDmg != null) { + const nd = Math.floor(Number(data.balloonBossBossDmg)); + if (Number.isFinite(nd) && nd >= 0) { + const prevD = Math.max(0, p.balloonBossBossDmg | 0); + if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd); + } + } + if (data.balloonBossBalloons != null) { + const nb = Math.floor(Number(data.balloonBossBalloons)); + if (Number.isFinite(nb) && nb >= 0) { + const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.floor(p.balloonBossBalloons) + : bbDefaultBalloons; + if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb; + } + } + if (data.balloonBossEliminated) p.balloonBossEliminated = true; + } + if (p) { + p.x = nx; + p.y = ny; + p.direction = data.direction || p.direction; + const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId }; + if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0); + if (md && md.gameType === 'balloon_boss') { + const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + out.balloonBossScore = Math.max(0, p.balloonBossScore | 0); + out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0); + out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.max(0, Math.floor(p.balloonBossBalloons)) + : bbDef; + out.balloonBossEliminated = !!p.balloonBossEliminated; + } + if (nx !== data.x || ny !== data.y) socket.emit('user-move', out); + socket.to(sid).emit('user-move', out); + } + break; + } + } + }); + + socket.on('lobby-interact', (data, ack) => { + const reply = typeof ack === 'function' ? ack : () => {}; + const tx = Math.floor(Number(data && data.x)); + const ty = Math.floor(Number(data && data.y)); + for (const [sid, space] of spaces) { + if (!space.peers.has(socket.id)) continue; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' }); + const w = md.width || 20; + const h = md.height || 15; + if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) { + return reply({ ok: false, error: 'จุดไม่ถูกต้อง' }); + } + if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) { + return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' }); + } + const px = Math.floor(Number(p.x)); + const py = Math.floor(Number(p.y)); + const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]]; + let near = false; + for (const [dx, dy] of neighbors) { + if (px + dx === tx && py + dy === ty) { near = true; break; } + } + if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' }); + if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' }); + const nick = (p.nickname || '').trim() || 'ผู้เล่น'; + io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty }); + return reply({ ok: true }); + } + reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' }); + }); + + socket.on('chat', (text) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + const nick = space.peers.get(socket.id)?.nickname || ''; + io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick }); + break; + } + } + }); + + socket.on('voice-state', ({ micOn }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.voiceMicOn = micOn !== false; + if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id]; + io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false }); + break; + } + } + }); + + socket.on('voice-activity', ({ level }) => { + const sid = socket.data.spaceId; + if (!sid || !spaces.get(sid)) return; + const until = Date.now() + 400; + io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until }); + }); + + socket.on('voice-init', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + if (!space.voiceInits) space.voiceInits = {}; + space.voiceInits[socket.id] = data; + io.to(sid).emit('voice-init', { from: socket.id, data }); + break; + } + } + }); + + socket.on('voice-chunk', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + socket.to(sid).emit('voice-chunk', { from: socket.id, data }); + break; + } + } + }); + + socket.on('webrtc-signal', (data) => { + const { to, type, sdp, candidate } = data; + if (!to) return; + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid) && space.peers.has(to)) { + io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate }); + break; + } + } + }); +}); + +loadMaps(); +server.listen(PORT, () => console.log('Game', PORT)); \ No newline at end of file diff --git a/www/html/Game/server.js.bak-20260521-224750 b/www/html/Game/server.js.bak-20260521-224750 new file mode 100644 index 0000000..01da48c --- /dev/null +++ b/www/html/Game/server.js.bak-20260521-224750 @@ -0,0 +1,4967 @@ +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { Server } = require('socket.io'); + +const PORT = process.env.PORT || 3001; +const BASE_PATH = '/Game'; +const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json'); +const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin'; +const adminTokens = new Set(); +/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */ +const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; +/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */ +const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; +/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */ +/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */ +const DETECTIVE_MINIGAME_POOL = [ + { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' }, + { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' }, + { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' }, + { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' }, + { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' }, + { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' }, + { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' }, +]; +const LOBBY_SLOT_TOTAL = 6; + +/** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */ +function effectiveBotSlotCount(space) { + let n = parseInt(space && space.botSlotCount, 10); + if (isNaN(n) || n < 0) n = 0; + const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0; + if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) { + n = LOBBY_SLOT_TOTAL - maxP; + if (space) space.botSlotCount = n; + } + return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n)); +} +/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */ +const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; +/** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */ +const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; +/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */ +const SPACE_EMPTY_TTL_MS = 45000; +// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025) +const AI_MODELS = [ + 'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano', + 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', + 'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research', + 'o1', 'o1-mini', 'o1-pro', + 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', + 'gpt-3.5-turbo', +]; + +function loadAiSettings() { + try { + if (fs.existsSync(AI_SETTINGS_PATH)) { + const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8'); + return JSON.parse(raw); + } + } catch (e) { console.error('loadAiSettings', e.message); } + return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' }; +} + +function saveAiSettings(settings) { + try { + const dir = path.dirname(AI_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch (e) { console.error('saveAiSettings', e.message); } +} + +function getAdminToken(cookieHeader) { + if (!cookieHeader) return null; + const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/); + return m && adminTokens.has(m[1]) ? m[1] : null; +} + +const MAPS_DIR = path.join(__dirname, 'data', 'maps'); +const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters'); +/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */ +const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; +const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right']; + +/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */ +function buildCharacterLayerManifest(id, files) { + if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} }; + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|'); + const byDir = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiHits = files.filter((f) => multiRe.test(f)); + if (multiHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiHits.forEach((f) => { + const m = f.match(multiRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDir[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return; + const m = f.match(singleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] }; + } + } + const byDirIdle = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiIdleHits = files.filter((f) => multiIdleRe.test(f)); + if (multiIdleHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiIdleHits.forEach((f) => { + const m = f.match(multiIdleRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDirIdle[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return; + const m = f.match(singleIdleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] }; + } + } + return { ok: true, id, byDir, byDirIdle }; +} + +/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */ +const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets'); +const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets'); +const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); +/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */ +const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets'); +const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json'); +/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */ +const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH + ? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim()) + : ''; +const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json'); +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 1–16 */ + carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(), + /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */ + carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(), + /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */ + carryChoicePlaqueMapScale: 1.25, + /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */ + carryWalkSpeedMultForMapId: '', + carryWalkSpeedMult: null, + /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */ + quizRoundQuestionCount: 10, + questions: [], + carryQuestions: [], + battleQuizMcq: [], + }; +} + +function defaultCarryMapPanelTheme() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + questionFontMinPx: 10, + questionFontMaxPx: 24, + }; +} + +function sanitizeCssColorToken(input, fallback) { + const t = String(input == null ? '' : input).trim().slice(0, 120); + if (!t) return fallback; + if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback; + 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)) { + const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, ''); + if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim(); + } + return fallback; +} + +function sanitizeCarryMapPanelTheme(raw) { + const d = defaultCarryMapPanelTheme(); + if (!raw || typeof raw !== 'object') return d; + let qMin = Number(raw.questionFontMinPx); + let qMax = Number(raw.questionFontMaxPx); + if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = d.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 t = qMin; + qMin = qMax; + qMax = t; + } + return { + panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg), + panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder), + borderWidthPx: (() => { + let w = Number(raw.borderWidthPx); + if (!Number.isFinite(w)) w = d.borderWidthPx; + return Math.max(0, Math.min(12, Math.round(w))); + })(), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; +} + +/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */ +function defaultCarryChoicePlaqueTheme() { + 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, + }; +} + +function sanitizeCarryChoicePlaqueTheme(raw) { + const d = defaultCarryChoicePlaqueTheme(); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({}); + 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: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder), + fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + borderWidthPx: bw, + plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl), + }; +} + +const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16; + +function defaultCarryChoicePlaqueThemes() { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme({})); + } + return out; +} + +/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */ +function sanitizeCarryChoicePlaqueThemes(raw) { + if (Array.isArray(raw) && raw.length > 0) { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {})); + } + return out; + } + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const one = sanitizeCarryChoicePlaqueTheme(raw); + return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one })); + } + return defaultCarryChoicePlaqueThemes(); +} + +function sanitizeCarryChoiceImageUrl(input) { + const s = String(input == null ? '' : input).trim().slice(0, 512); + if (!s) return ''; + if (/[\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 u = new URL(s); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + return s; + } catch (e) { + return ''; + } +} + +function defaultCarryEmbedCountdownTheme() { + 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 sanitizeCarryEmbedCountdownTheme(raw) { + const d = defaultCarryEmbedCountdownTheme(); + 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))); + }; + return { + overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop), + innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg), + innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder), + innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), + innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), + digitColor: sanitizeCssColorToken(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), + }; +} + +/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */ +const QUIZ_BATTLE_CATEGORY_IDS = new Set([ + 'cybercrime', + 'cyber_law', + 'privacy', + 'digital_security', + 'social_media', + 'ethics', + 'ai_data', +]); + +/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */ +function sanitizeBattleQuizMcq(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + let categoryId = String(q.categoryId || '').trim(); + if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) { + categoryId = 'cybercrime'; + } + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + const rawChoices = Array.isArray(q.choices) ? q.choices : []; + const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160)); + if (choices.length !== 3 || !choices.every(Boolean)) continue; + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(2, Math.floor(ci))); + out.push({ categoryId, text, choices, correctIndex: ci }); + if (out.length >= 200) break; + } + return out; +} + +/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */ +function sanitizeCarryQuestions(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + let choices = Array.isArray(q.choices) + ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean) + : []; + if (choices.length < 2) continue; + if (choices.length > 16) choices = choices.slice(0, 16); + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci))); + const row = { text, choices, correctIndex: ci }; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx])); + if (urls.some((u) => u)) row.choiceImageUrls = urls; + } + out.push(row); + if (out.length >= 80) break; + } + return out; +} + +function clampQuizMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(1000, Math.min(300000, Math.floor(v))); +} + +function clampQuizBetweenMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(300000, Math.floor(v))); +} + +function clampCarrySessionLength(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); +} + +/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */ +function clampQuizRoundQuestionCount(n, def) { + const v = Number(n); + if (!Number.isFinite(v)) { + const d = Number(def); + if (!Number.isFinite(d)) return 10; + return Math.max(1, Math.min(50, Math.floor(d))); + } + return Math.max(1, Math.min(50, Math.floor(v))); +} + +function clampCarryChoicePlaqueMapScale(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100; +} + +/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */ +function sanitizeCarryWalkSpeedMultForMapId(raw) { + const t = String(raw == null ? '' : raw).trim().slice(0, 64); + if (!t) return ''; + if (!/^[a-zA-Z0-9_-]+$/.test(t)) return ''; + return t; +} + +/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */ +function clampCarryWalkSpeedMultSetting(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v)) return null; + return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; +} + +function mergeQuizCarryFromMirrorIfSet(base) { + if (!QUIZ_SETTINGS_MIRROR_PATH) return base; + try { + if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base; + const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8'); + const j = JSON.parse(raw); + if (!j || typeof j !== 'object') return base; + if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') { + base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + } + if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') { + base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + } + if (j.carryReadMs != null) { + base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs); + } + if (j.carryAnswerMs != null) { + base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs); + } + if (j.carrySessionLength != null) { + base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength); + } + if (j.carryWalkSpeedMultForMapId != null) { + base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + } + if (j.carryWalkSpeedMult != null) { + const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (wm != null) base.carryWalkSpeedMult = wm; + } + if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') { + base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + } + if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } + if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) { + base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + } + if (j.quizRoundQuestionCount != null) { + base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount); + } + return base; + } catch (e) { + console.error('mergeQuizCarryFromMirrorIfSet', e.message); + return base; + } +} + +function loadQuizSettings() { + try { + if (fs.existsSync(QUIZ_SETTINGS_PATH)) { + const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8')); + const d = defaultQuizSettings(); + const questions = Array.isArray(j.questions) ? j.questions : []; + const carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq); + const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length + ? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes) + : sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale); + const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null; + else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + const out = { + readMs: clampQuizMs(j.readMs, d.readMs), + answerMs: clampQuizMs(j.answerMs, d.answerMs), + betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs), + carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs), + carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs), + carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength), + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount), + questions, + carryQuestions, + battleQuizMcq, + }; + return mergeQuizCarryFromMirrorIfSet(out); + } + } catch (e) { console.error('loadQuizSettings', e.message); } + return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings()); +} + +const GAUNTLET_DEFAULT_TICK_MS = 220; +const GAUNTLET_DEFAULT_JUMP_TICKS = 16; +const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0; +/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */ +const STACK_SWING_HZ_DEFAULT = 0.55; + +function defaultGameTiming() { + return { + gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS, + gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS, + gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC, + ...defaultGauntletVisuals(), + stackSwingHz: STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: null, + /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */ + jumpSurviveJumpHeightMult: 1.5, + /** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */ + jumpSurviveMissionTimeSec: 0, + /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ + jumpSurvivePlatformTiles: [ + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + ], + /** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */ + stackTowerMissionTimeSec: 90, + /** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */ + stackTeamMissesMax: 3, + /** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ + stackTowerProgressBlocks: 50, + stackBlockNormalImageUrls: ['', '', '', '', '', ''], + stackBlockHeavyImageUrls: ['', '', '', '', '', ''], + /** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */ + stackHeavyBlockPercent: 35, + /** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */ + spaceShooterMissionTimeSec: 0, + spaceShooterShipImageUrls: ['', '', '', '', '', ''], + spaceShooterAsteroidSpriteUrls: [], + spaceShooterAsteroidExplodeFrameMs: 70, + /** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ + spaceShooterAsteroidIntervalMs: 1040, + spaceShooterShipDamageOverlayUrls: ['', '', ''], + /** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */ + balloonBossMissionTimeSec: 0, + balloonBossBossImageUrl: '', + balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''], + /** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */ + balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png', + }; +} + +function clampStackSwingHz(n) { + const v = Number(n); + if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT; + return Math.max(0.08, Math.min(2.8, v)); +} + +/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */ +function clampStackBlockWidthTiles(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return null; + return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100; +} + +function clampStackTowerMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 90; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function clampStackTeamMissesMax(n) { + const v = Number(n); + if (Number.isNaN(v)) return 3; + return Math.max(1, Math.min(20, Math.floor(v))); +} + +function clampStackTowerProgressBlocks(n) { + const v = Number(n); + if (Number.isNaN(v)) return 50; + return Math.max(1, Math.min(500, Math.floor(v))); +} + +/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */ +function normalizeStackBlockSixImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +function clampStackHeavyBlockPercent(n) { + const v = Number(n); + if (Number.isNaN(v)) return 35; + return Math.max(0, Math.min(100, Math.floor(v))); +} + +function clampJumpSurviveJumpHeightMult(n) { + const v = Number(n); + if (Number.isNaN(v)) return 1.5; + return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100; +} + +/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 10–7200 */ +function clampJumpSurviveMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */ +function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = []; + } + } + if (!Array.isArray(arr)) arr = []; + const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : ''; + const out = []; + for (let i = 0; i < 3; i++) { + const o = arr[i]; + const obj = o && typeof o === 'object' ? o : {}; + let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : ''); + if (i === 0 && !url && legacy) url = legacy; + let ww = Number(obj.w); + let hh = Number(obj.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))); + out.push({ url, w: ww, h: hh }); + } + return out; +} + +function clampSpaceShooterMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */ +function normalizeSpaceShooterShipImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */ +function normalizeSpaceShooterAsteroidSpriteUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < arr.length && out.length < 32; i++) { + const u = sanitizeGauntletAssetUrl(arr[i]); + if (u) out.push(u); + } + return out; +} + +function clampSpaceShooterAsteroidExplodeFrameMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return 70; + return Math.max(30, Math.min(500, Math.round(v))); +} + +const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040; +function clampSpaceShooterAsteroidIntervalMs(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + return Math.max(200, Math.min(10000, Math.floor(v))); +} + +/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */ +function normalizeSpaceShooterShipDamageOverlayUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = ['', '', '']; + for (let i = 0; i < 3; i++) { + out[i] = sanitizeGauntletAssetUrl(arr[i]); + } + return out; +} + +function clampGauntletTickMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS; + return Math.max(80, Math.min(800, Math.floor(v))); +} + +function clampGauntletJumpTicks(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS; + return Math.max(4, Math.min(40, Math.floor(v))); +} + +/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */ +function clampGauntletTimeLimitSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function defaultGauntletVisuals() { + return { + gauntletLaneImageUrls: [], + gauntletLaserTopUrl: '', + gauntletLaserBottomUrl: '', + gauntletLaserLineUrl: '', + gauntletLaserFillColor: 'rgba(140,230,255,0.42)', + gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)', + gauntletLaserLineWidthPx: 2, + }; +} + +/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */ +function encodeSpacesInAssetUrlPath(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; +} + +/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */ +function normalizeGameAssetUrlForWeb(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; +} + +/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */ +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; +} + +/** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */ +function fixBareMegaVirusArtboardUrl(t) { + const s = String(t || '').trim().replace(/\/+$/, ''); + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + return t; +} + +function sanitizeGauntletAssetUrl(s) { + const t = fixBareMegaVirusArtboardUrl( + normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '') + ); + if (!t || t.length > 500) return ''; + /** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */ + if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return ''; + /** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */ + if (/\.{4,}/.test(t)) return ''; + if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t); + if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t); + /** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */ + if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`); + return ''; +} + +function clampGauntletLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t) return def; + if (/[<>"'`]/.test(t)) return def; + return t; +} + +function clampGauntletLaserLineWidthPx(n) { + const v = Number(n); + if (Number.isNaN(v)) return 2; + return Math.max(0, Math.min(24, Math.round(v))); +} + +function normalizeGauntletVisualsFromRequest(d) { + const def = defaultGauntletVisuals(); + if (!d || typeof d !== 'object') return { ...def }; + let laneArr = d.gauntletLaneImageUrls; + if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/); + if (!Array.isArray(laneArr)) laneArr = []; + const gauntletLaneImageUrls = []; + for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) { + const u = sanitizeGauntletAssetUrl(laneArr[i]); + if (u) gauntletLaneImageUrls.push(u); + } + return { + gauntletLaneImageUrls, + gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl), + gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl), + gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl), + gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor), + gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor), + gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx), + }; +} + +function getGauntletVisualsForClient() { + const r = runtimeGameTiming; + const d = defaultGauntletVisuals(); + return { + gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls, + gauntletLaserTopUrl: r.gauntletLaserTopUrl || '', + gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '', + gauntletLaserLineUrl: r.gauntletLaserLineUrl || '', + gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor, + gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor, + gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx, + }; +} + +function loadGameTiming() { + try { + if (fs.existsSync(GAME_TIMING_PATH)) { + const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8')); + const vis = normalizeGauntletVisualsFromRequest(j); + return { + gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec), + ...vis, + stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz') + ? clampStackSwingHz(j.stackSwingHz) + : STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(j.stackBlockWidthTiles) + : null, + stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec) + : 90, + stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(j.stackTeamMissesMax) + : 3, + stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks) + : 50, + stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls), + stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls), + stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent) + : 35, + jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult) + : 1.5, + jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec) + : 0, + jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming( + j.jumpSurvivePlatformTiles, + j.jumpSurvivePlatformTileUrl, + ), + spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec) + : 0, + spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls), + spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls), + spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs) + : 70, + spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs) + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT, + spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls), + balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec) + : 0, + balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl) + : '', + balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls), + balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl) + : '', + }; + } + } catch (e) { console.error('loadGameTiming', e.message); } + return defaultGameTiming(); +} + +let runtimeGameTiming = loadGameTiming(); + +function getGauntletTickMs() { + return runtimeGameTiming.gauntletTickMs; +} + +function getGauntletJumpTicks() { + return runtimeGameTiming.gauntletJumpTicks; +} + +function getGauntletTimeLimitSec() { + return runtimeGameTiming.gauntletTimeLimitSec || 0; +} + +function saveGameTimingToFile(d) { + try { + const dir = path.dirname(GAME_TIMING_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const prev = { ...runtimeGameTiming }; + const vis = normalizeGauntletVisualsFromRequest(d); + const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz') + ? clampStackSwingHz(d.stackSwingHz) + : clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT); + const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(d.stackBlockWidthTiles) + : (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles') + ? prev.stackBlockWidthTiles + : null); + const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult') + ? prev.jumpSurviveJumpHeightMult + : 1.5; + const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult) + : clampJumpSurviveJumpHeightMult(prevMult); + const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec') + ? prev.jumpSurviveMissionTimeSec + : 0; + const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec) + : clampJumpSurviveMissionTimeSec(prevJumpMissionSec); + const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming( + prev.jumpSurvivePlatformTiles, + prev.jumpSurvivePlatformTileUrl, + ); + let jumpSurvivePlatformTiles; + if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + d.jumpSurvivePlatformTiles, + d.jumpSurvivePlatformTileUrl, + ); + } else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + [ + { url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 }, + prevJumpTiles[1], + prevJumpTiles[2], + ], + '', + ); + } else { + jumpSurvivePlatformTiles = prevJumpTiles; + } + const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec') + ? prev.spaceShooterMissionTimeSec + : 0; + const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec); + const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls) + ? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls) + : normalizeSpaceShooterShipImageUrls([]); + const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls') + ? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls) + : prevShipUrls; + const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls) + ? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls) + : []; + const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls') + ? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls) + : prevAstUrls; + const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs') + ? prev.spaceShooterAsteroidExplodeFrameMs + : 70; + const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs) + : clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms); + const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs') + ? prev.spaceShooterAsteroidIntervalMs + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs) + : clampSpaceShooterAsteroidIntervalMs(prevAstIv); + const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls) + ? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls) + : normalizeSpaceShooterShipDamageOverlayUrls([]); + const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls') + ? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls) + : prevDmg; + const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec') + ? prev.balloonBossMissionTimeSec + : 0; + const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevBbMission); + const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl) + : ''; + const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl) + : prevBbBoss; + const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls) + ? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls) + : normalizeStackBlockSixImageUrls([]); + const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls') + ? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls) + : prevBbBalloons; + const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl) + : ''; + const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl) + : prevBbFb; + const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec') + ? prev.stackTowerMissionTimeSec + : 90; + const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec) + : clampStackTowerMissionTimeSec(prevStackTowerSec); + const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax') + ? prev.stackTeamMissesMax + : 3; + const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(d.stackTeamMissesMax) + : clampStackTeamMissesMax(prevStackMisses); + const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks') + ? prev.stackTowerProgressBlocks + : 50; + const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks) + : clampStackTowerProgressBlocks(prevStackProgBlocks); + const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls) + : normalizeStackBlockSixImageUrls([]); + const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls) + : normalizeStackBlockSixImageUrls([]); + const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls) + : prevStackNorm; + const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls) + : prevStackHeavy; + const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent) + : 35; + const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent) + : prevHeavyPct; + const out = { + gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec), + ...vis, + stackSwingHz: stackHz, + stackBlockWidthTiles: stackBlockW, + stackTowerMissionTimeSec, + stackTeamMissesMax, + stackTowerProgressBlocks, + stackBlockNormalImageUrls, + stackBlockHeavyImageUrls, + stackHeavyBlockPercent, + jumpSurviveJumpHeightMult: jumpMult, + jumpSurviveMissionTimeSec: jumpMissionSec, + jumpSurvivePlatformTiles, + spaceShooterMissionTimeSec: spaceShooterMissionSec, + spaceShooterShipImageUrls, + spaceShooterAsteroidSpriteUrls, + spaceShooterAsteroidExplodeFrameMs, + spaceShooterAsteroidIntervalMs, + spaceShooterShipDamageOverlayUrls, + balloonBossMissionTimeSec, + balloonBossBossImageUrl, + balloonBossPlayerBalloonImageUrls, + balloonBossPlayerBalloonFallbackUrl, + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + 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 }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(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 serverWallCollisionTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + 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) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(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) => quizCellOn(tGrid, x, y) || quizCellOn(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 validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(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; +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + const shuffled = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + space.suspectCardMinigames = shuffled.slice(0, 3).map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = เริ่มนับหลักฐาน (ติกถูก) ใหม่ทั้งหมด + space.suspectProgress = [0, 0, 0]; +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + + let si = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, si++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message) { + ensurePostCaseLobbyMapLoaded(); + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + stopGauntletTicker(space); + space.gauntletRun = null; + // เล่นมินิเกมจบ → เติมหลักฐาน (ติกถูก) 1 ช่องให้ผู้ต้องสงสัยที่กำลังสืบ (สะสมได้สูงสุด 3) + if (!Array.isArray(space.suspectProgress) || space.suspectProgress.length !== 3) space.suspectProgress = [0, 0, 0]; + const _ai = (typeof space.suspectActiveIndex === 'number') ? space.suspectActiveIndex : -1; + if (_ai >= 0 && _ai <= 2) { + space.suspectProgress[_ai] = Math.min(3, (space.suspectProgress[_ai] || 0) + 1); + } + space.suspectActiveIndex = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, gi++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const payload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + detectiveReturn: true, + }; + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — กลับ LobbyB', returnToLobbyB: true }); + io.to(sid).emit('detective-minigame-ended', payload); + io.to(sid).emit('game-start', payload); +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + returnDetectiveSpaceToLobbyB(sid, space, message); + space.quizSession = null; + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + if (allWrong) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + scheduleQuizReadPhase(sid, space, md); +} + +function safeMapId(id) { + return (id || '').replace(/[^a-z0-9_-]/gi, '') || null; +} + +/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */ +function normalizeQuizCarryLayersOnMap(m) { + if (!m || m.gameType !== 'quiz_carry') return; + const w = m.width || 20, h = m.height || 15; + const normHub = () => { + const src = m.quizCarryHubArea || []; + 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); + } + m.quizCarryHubArea = rows; + }; + const normOptions = () => { + const src = m.quizCarryOptionArea || []; + 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 <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0); + } + rows.push(row); + } + m.quizCarryOptionArea = rows; + }; + const normCountdown = () => { + const src = m.carryEmbedCountdownArea || []; + 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]; + row.push(Number(v) === 1 ? 1 : 0); + } + rows.push(row); + } + m.carryEmbedCountdownArea = rows; + }; + normHub(); + normOptions(); + normCountdown(); +} + +/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */ +function normalizeQuizBattleDomeAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattleDomeArea || []; + 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); + } + m.quizBattleDomeArea = rows; +} + +function normalizeQuizBattlePathAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattlePathArea || []; + 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); + } + m.quizBattlePathArea = rows; +} + +function quizBattlePathModeActiveServer(m) { + if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false; + const g = m.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 quizBattleFootprintFullyOnPathServer(m, px, py) { + if (!quizBattlePathModeActiveServer(m)) return true; + const g = m.quizBattlePathArea; + for (const k of quizCarryFootprintTileKeys(m, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; +} + +function serverFootprintClearOfWalls(md, px, py) { + const w = md.width || 20, h = md.height || 15; + for (const k of serverWallCollisionTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) return false; + } + return true; +} + +/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */ +function snapPositionOntoQuizBattlePathServer(md, px, py) { + if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py }; + if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py }; + const w = md.width || 20, h = md.height || 15; + const g = md.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 (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue; + if (!serverFootprintClearOfWalls(md, 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 }; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) 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 }; +} + +function normalizeJumpSurvivePlatformAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurvivePlatformArea = rows; +} + +function normalizeJumpSurvivePlatformVariantAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const pa = m.jumpSurvivePlatformArea || []; + const src = m.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); + } + m.jumpSurvivePlatformVariantArea = rows; +} + +function normalizeJumpSurviveHazardAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurviveHazardArea = rows; +} + +/** + * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON + */ +function applyStackTowerMissionMapPlayPolicy(safeId, m) { + if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return; + const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {}; + const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); + const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up'; + const spNum = Number(raw.speedPxPerSec); + const speedPxPerSec = Number.isFinite(spNum) + ? Math.max(0, Math.min(400, Math.floor(spNum))) + : 0; + const sel = Math.floor(Number(raw.stepEveryLayers)); + const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0; + const ssp = Math.floor(Number(raw.stepScrollPx)); + const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0; + const sam = Math.floor(Number(raw.stepAnimMs)); + const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520; + const st = { + enabled: raw.enabled === true, + speedPxPerSec, + scrollDirection, + stepEveryLayers, + stepScrollPx, + stepAnimMs, + }; + if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage; + if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage; + const rg = Number(raw.releaseGapWorldPx); + if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg)); + m.stackTowerBgScroll = st; + const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {}; + const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12)); + const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260)); + m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx }; +} + +function loadMaps() { + try { + if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true }); + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(MAPS_DIR); + for (const f of files) { + if (!f.endsWith('.json')) continue; + const id = f.slice(0, -5); + if (!safeMapId(id)) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(id, data); + maps.set(id, data); + } catch (e) { console.error('load map', f, e.message); } + } + } catch (e) { console.error('loadMaps', e.message); } +} + +/** + * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที + * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า) + */ +function rehydrateSingleMapFromDisk(safeId) { + if (!safeId) return false; + const fp = path.join(MAPS_DIR, safeId + '.json'); + if (!fs.existsSync(fp)) return false; + try { + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(safeId, data); + maps.set(safeId, data); + return true; + } catch (e) { + console.error('rehydrate map', safeId, e.message); + return false; + } +} + +/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */ +function ensurePostCaseLobbyMapLoaded() { + if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true; + try { + const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json'); + if (!fs.existsSync(fp)) return false; + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + maps.set(POST_CASE_LOBBY_SPACE_ID, data); + return true; + } catch (e) { + console.error('ensurePostCaseLobbyMapLoaded', e.message); + return false; + } +} + +function saveMap(id, m) { + const safe = safeMapId(id); + if (!safe) return; + try { + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8'); + } catch (e) { console.error('saveMap', id, e.message); } +} + +function isMapNameTaken(name, excludeId) { + const n = (name || '').trim().toLowerCase(); + for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true; + return false; +} + +const server = http.createServer((req, res) => { + let url = req.url; + if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html'; + if (url.startsWith(BASE_PATH + '/api/maps')) { + let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0]; + if (id.includes('?')) id = id.split('?')[0]; + if (req.method === 'GET' && id) { + const sid = safeMapId(decodeURIComponent(id)); + if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + rehydrateSingleMapFromDisk(sid); + const m = maps.get(sid); + if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m)); + } + if (req.method === 'GET' && !id) { + const list = [...maps].map(([id, m]) => ({ id, name: m.name })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || 'ฉากใหม่'; + if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const id = Date.now().toString(36); + const m = { ...defaultMap(), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (req.method === 'PUT' && id && maps.has(id)) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || maps.get(id).name; + if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const m = { ...maps.get(id), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + applyStackTowerMissionMapPlayPolicy(id, m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + const quizCarryDiskPaths = new Set([ + BASE_PATH + '/api/quiz-carry-from-disk', + BASE_PATH + '/api-quiz-carry-from-disk.php', + ]); + if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) { + if (req.method === 'GET') { + const g = loadQuizSettings(); + const slice = { + carryMapPanelTheme: g.carryMapPanelTheme, + carryEmbedCountdownTheme: g.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: g.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: g.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale, + carryReadMs: g.carryReadMs, + carryAnswerMs: g.carryAnswerMs, + carrySessionLength: g.carrySessionLength, + carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: g.carryWalkSpeedMult, + carryQuestions: g.carryQuestions, + }; + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(slice)); + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(loadQuizSettings())); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveQuizSettings(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + battleQuizMcqSaved: saved.battleQuizMcqSaved, + carryQuestionsSaved: saved.carryQuestionsSaved, + carryMapPanelTheme: saved.carryMapPanelTheme, + carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + }); + return res.end(JSON.stringify({ ...runtimeGameTiming })); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveGameTimingToFile(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + const { ok: _ok, error: _err, ...timingRest } = saved; + return res.end(JSON.stringify({ ok: true, ...timingRest })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() })); + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureGauntletAssetsDir(); + const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : ''; + let fname; + if (replaceFilename) { + const s = safeGauntletStoredFilename(replaceFilename); + if (!s) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' })); + } + const cur = path.join(GAUNTLET_ASSETS_DIR, s); + if (!fs.existsSync(cur)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' })); + } + const norm = (e) => { + let x = String(e || '').toLowerCase(); + if (x.startsWith('.')) x = x.slice(1); + return x === 'jpeg' ? 'jpg' : x; + }; + if (norm(path.extname(s)) !== norm(ext)) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' })); + } + fname = s; + } else { + fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`; + } + fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf); + const meta = loadGauntletAssetsMeta(); + const prev = meta[fname] || {}; + meta[fname] = { + ...prev, + label: labelIn || prev.label || fname, + updatedAt: Date.now(), + }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/gauntlet-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureQuizCarryPlaqueAssetsDir(); + const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`; + fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const fname = safeGauntletStoredFilename(d.filename); + if (!fname) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' })); + } + const meta = loadGauntletAssetsMeta(); + const label = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') { + try { + const q = url.includes('?') ? url.split('?')[1] : ''; + const sp = new URLSearchParams(q); + const rawFn = sp.get('file'); + const fname = safeGauntletStoredFilename(rawFn); + if (!fname) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + const meta = loadGauntletAssetsMeta(); + delete meta[fname]; + saveGauntletAssetsMeta(meta); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' })); + } + } + if (url === BASE_PATH + '/api/characters' && req.method === 'GET') { + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const ids = new Set(); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + files.forEach(f => { + let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + }); + const idArr = [...ids]; + const oldestMsById = {}; + idArr.forEach((id) => { + let oldest = Infinity; + const prefix = id + '_'; + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + try { + const st = fs.statSync(path.join(CHARACTERS_DIR, f)); + const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime(); + if (t < oldest) oldest = t; + } catch (e) { /* ignore */ } + }); + oldestMsById[id] = oldest === Infinity ? 0 : oldest; + }); + idArr.sort((a, b) => { + const ta = oldestMsById[a] || 0; + const tb = oldestMsById[b] || 0; + if (ta !== tb) return ta - tb; + return String(a).localeCompare(String(b)); + }); + const list = idArr.map((id) => { + const prefix = id + '_'; + const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f)); + const layerNameSet = new Set(); + if (hasLayerFiles) { + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) { + layerNameSet.add(m[1]); + return; + } + m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) layerNameSet.add(m[1]); + }); + } + const layers = layerNameSet.size ? [...layerNameSet].sort() : []; + const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null; + return { id, name: id, hasLayerFiles, layers, layerManifest }; + }); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(list)); + } catch (e) { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify([])); + } + } + const urlPath = url.split('?')[0]; + { + const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$')); + if (lm && req.method === 'GET') { + const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const manifest = buildCharacterLayerManifest(id, files); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(manifest)); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} })); + } + } + } + if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') { + if (/\/layer-manifest$/i.test(urlPath)) { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' })); + } + const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length)); + const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + let removed = 0; + files.forEach(f => { + if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) { + try { + fs.unlinkSync(path.join(CHARACTERS_DIR, f)); + removed++; + } catch (e) { + // ignore single file errors + } + } + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, removed })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') })); + } + return; + } + if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + if (!body || body.length < 2) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' })); + } + const d = JSON.parse(body); + const dirs = ['up', 'down', 'left', 'right']; + const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now(); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 }; + let walkWritten = 0; + for (const dir of dirs) { + let data = d[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + // เขียนไฟล์เฟรม (สำหรับ animation) + const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + walkWritten++; + frameIndex++; + } + } + let layerWritten = 0; + // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html) + const lf = d.layerFrames; + if (lf && typeof lf === 'object') { + for (const dir of dirs) { + const arr = lf[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const lfIdle = d.layerFramesIdle; + if (lfIdle && typeof lfIdle === 'object') { + for (const dir of dirs) { + const arr = lfIdle[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_idle_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null; + if (idleObj) { + for (const dir of dirs) { + let data = idleObj[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameFrame = list.length > 1 + ? (id + '_' + dir + '_idle_' + frameIndex + '.png') + : (id + '_' + dir + '_idle.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '_idle.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + frameIndex++; + } + } + } + if (walkWritten === 0 && layerWritten === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' })); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, characterId: id })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/api/spaces')) { + const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean); + const spaceId = parts[0]; + if (req.method === 'GET' && !spaceId) { + const now = Date.now(); + for (const [id, s] of [...spaces.entries()]) { + if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue; + const n = s.peers ? s.peers.size : 0; + if (n !== 0) continue; + const created = s.createdAt; + const stale = created == null || now - created > SPACE_EMPTY_TTL_MS; + if (stale) spaces.delete(id); + } + const list = [...spaces] + .filter(([, s]) => !s.isPrivate) + .filter(([, s]) => (s.peers && s.peers.size > 0)) + .map(([id, s]) => ({ + spaceId: id, + spaceName: s.spaceName || id, + mapName: (s.mapData && s.mapData.name) || '—', + peerCount: s.peers ? s.peers.size : 0, + maxPlayers: s.maxPlayers || 10 + })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'GET' && spaceId) { + const s = spaces.get(spaceId); + if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name })); + } + if (req.method === 'POST' && !spaceId) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body || '{}'); + const mapData = d.mapId ? maps.get(d.mapId) : null; + if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' })); + const isPrivate = !!d.isPrivate; + let sid; + if (isPrivate) { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code; + for (let i = 0; i < 20; i++) { + code = ''; + for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)]; + if (!spaces.has(code)) { sid = code; break; } + } + if (!sid) sid = 'priv-' + Date.now(); + } else { + const base = (d.name || '').trim() || 'space'; + sid = base + '-' + Date.now(); + } + const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid); + let maxPlayers = parseInt(d.maxPlayers, 10); + if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10; + maxPlayers = Math.min(10, Math.max(1, maxPlayers)); + if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6); + const mapId = d.mapId ? String(d.mapId).trim() : null; + const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview'; + let botSlotCount = parseInt(d.botSlotCount, 10); + if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0; + botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers }); + spaces.set(sid, { + mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(), + previewEditorTest, + botSlotCount, + suspectCardMinigames: null, + suspectPhaseActive: false, + suspectPickIndex: 0, + detectiveMinigameActive: false, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') { + function sendIceServers(iceServers) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ iceServers: iceServers || [] })); + } + function fallback() { + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + } + const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY; + const meteredAppId = process.env.METERED_TURN_APP_ID; + let meteredBase = process.env.METERED_TURN_URL; + if (!meteredBase) { + let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret'; + if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live'; + meteredBase = 'https://' + meteredHost; + } + meteredBase = meteredBase.replace(/\/$/, ''); + if (meteredKey) { + function tryNext(credUrl) { + return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) { + if (data && (data.error || data.message)) throw new Error(data.error || data.message); + const list = Array.isArray(data) ? data : (data && data.iceServers); + if (list && list.length) return sendIceServers(list); + if (data && data.data && Array.isArray(data.data) && data.data.length) { + const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca'; + const built = data.data.map(function (c) { + return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password }; + }); + return sendIceServers(built); + } + throw new Error('No iceServers'); + }); + } + const urlsToTry = [ + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey), + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey), + meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey) + ]; + let p = tryNext(urlsToTry[0]); + for (let i = 1; i < urlsToTry.length; i++) { + p = p.catch(function () { return tryNext(urlsToTry[i]); }); + } + p.catch(fallback); + return; + } + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + return; + } + if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const password = (d.password || '').trim(); + if (password !== ADMIN_PASSWORD) { + return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' })); + } + const token = require('crypto').randomBytes(24).toString('hex'); + adminTokens.add(token); + res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax'); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + const s = loadAiSettings(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + model: s.model || 'gpt-4o-mini', + models: AI_MODELS, + hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0), + intent: s.intent != null ? s.intent : '', + rag_enabled: !!s.rag_enabled, + rag_context: s.rag_context != null ? s.rag_context : '', + })); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const s = loadAiSettings(); + if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim(); + if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini'; + if (d.intent !== undefined) s.intent = String(d.intent); + if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled; + if (d.rag_context !== undefined) s.rag_context = String(d.rag_context); + saveAiSettings(s); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const message = (d.message || d.text || '').trim(); + if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' })); + const s = loadAiSettings(); + if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' })); + const model = s.model || 'gpt-4o-mini'; + const messages = []; + const systemParts = []; + if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim()); + if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) { + systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim()); + } + if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') }); + messages.push({ role: 'user', content: message }); + const payload = JSON.stringify({ + model: model, + messages: messages, + max_tokens: 500, + }); + const urlObj = new URL('https://api.openai.com/v1/chat/completions'); + const opts = { + hostname: urlObj.hostname, + path: urlObj.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + s.openai_api_key, + 'Content-Length': Buffer.byteLength(payload, 'utf8'), + }, + }; + const reqOut = require('https').request(opts, (resOut) => { + let data = ''; + resOut.on('data', chunk => data += chunk); + resOut.on('end', () => { + try { + const parsed = JSON.parse(data); + const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content; + if (text != null) { + res.writeHead(200); + res.end(JSON.stringify({ response: String(text).trim() })); + } else { + res.writeHead(502); + res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' })); + } + } catch (e) { + res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' })); + } + }); + }); + reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); }); + reqOut.write(payload); + reqOut.end(); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') { + const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0]; + const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, ''); + if (fname && fname.length > 0) { + const fp = path.join(CHARACTERS_DIR, fname); + if (fs.existsSync(fp)) { + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(data); + }); + } + } + } + if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') { + const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]); + const fname = safeGauntletStoredFilename(sub); + if (!fname) { + res.writeHead(400); + return res.end(); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(); + } + const ext = path.extname(fname); + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) }); + res.end(data); + }); + } + if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html'; + const filePath = path.join(__dirname, 'public', url); + const ext = path.extname(filePath); + const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' }; + fs.readFile(filePath, (err, data) => { + if (err) return res.writeHead(404), res.end('Not Found'); + res.writeHead(200, { + 'Content-Type': types[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + res.end(data); + }); +}); + +const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } }); +const spaces = new Map(); + +const GAUNTLET_MAX_PLAYERS = 6; +/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */ +/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */ +const GAUNTLET_LASER_SPAWN_CHANCE = 0.14; +/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */ +const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34; +/** Last Light / museum gauntlet — scoring, mission UI, how-to flow */ +const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07'; +/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */ +const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; +/** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */ +const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; + +function isGauntletCrownHeistMapBySpace(space) { + return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); +} + +function isBalloonBossMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'balloon_boss'); +} + +/** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */ +function isQuizQuestionMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'quiz'); +} + +function isStackTowerMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'stack'); +} + +function isJumpSurviveMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'jump_survive'); +} + +function isSpaceShooterMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'space_shooter'); +} + +function isCrownLobbyShellSpace(space) { + return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space); +} + +function newBalloonBossShellGauntletRunState() { + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 999, + crownRunHeld: true, + timerId: null, + endsAt: null, + }; +} + +function emitBalloonBossMissionShellGauntletSync(sid, space) { + if (!isBalloonBossMissionShellSpace(space)) return; + const gr = space.gauntletRun; + io.to(sid).emit('gauntlet-sync', { + gauntletCrownRunHeld: !!(gr && gr.crownRunHeld), + }); +} + +function rollGauntletCrownRewardCard(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' }; +} + +function gauntletCrownRankBonus(rankOrdinal) { + if (rankOrdinal === 1) return 100; + if (rankOrdinal === 2) return 80; + if (rankOrdinal === 3) return 60; + return 0; +} + +function buildGauntletCrownMissionPayload(space) { + const peersArr = [...space.peers.values()]; + const rows = peersArr.map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + characterId: p.characterId ?? null, + baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0), + eliminated: !!p.gauntletEliminated, + })); + rows.sort((a, b) => { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th'); + }); + let lastScore = null; + let rankOrdinal = 0; + const ranked = []; + for (let i = 0; i < rows.length; i++) { + const pos = i + 1; + if (lastScore !== rows[i].baseScore) { + rankOrdinal = pos; + lastScore = rows[i].baseScore; + } + const bonus = gauntletCrownRankBonus(rankOrdinal); + const finalScore = rows[i].baseScore + bonus; + const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal); + ranked.push({ + id: rows[i].id, + nickname: rows[i].nickname, + characterId: rows[i].characterId, + baseScore: rows[i].baseScore, + eliminated: rows[i].eliminated, + rank: rankOrdinal, + rankLabel, + rankBonus: bonus, + finalScore, + }); + } + const totalSum = ranked.reduce((s, r) => 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 = rollGauntletCrownRewardCard(grade); + return { + ranked, + totalSum, + averageScore, + grade, + rewardCard, + totalParts: ranked.map((r) => r.finalScore), + }; +} + +function gauntletSpawnYs(md, playerCount) { + const h = md.height || 15; + const lo = 1; + const hi = Math.max(lo, h - 2); + const n = Math.min(GAUNTLET_MAX_PLAYERS, 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; +} + +/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */ +function collectGauntletSpawnSlotsFromSpawnArea(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 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y }); + } + } + slots.sort((a, b) => a.y - b.y || a.x - b.x); + return slots; +} + +/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */ +function collectGauntletSpawnSlotsFromMap(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_MAX_PLAYERS) 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 (!isMapTileWalkableForSpawn(md, x, y)) continue; + slots.push({ x, y }); + } + if (slots.length > 0) return slots; + } + return collectGauntletSpawnSlotsFromSpawnArea(md); +} + +function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) { + const w = md.width || 20; + if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX; + if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback; + for (let d = 0; d < w; d++) { + if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d; + if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d; + } + return Math.max(0, Math.min(w - 1, spawnColX)); +} + +/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */ +function gauntletSpawnPositions(md, playerCount) { + const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount)); + const slots = collectGauntletSpawnSlotsFromMap(md); + const fallbackYs = gauntletSpawnYs(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 = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX); + out.push({ x, y }); + } + return out; +} + +function stopGauntletTicker(space) { + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } +} + +/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */ +function ensureGauntletEndsAtIfNeeded(space) { + const gr = space.gauntletRun; + if (!gr) return; + if (gr.crownRunHeld) return; + const lim = getGauntletTimeLimitSec(); + if (lim > 0 && gr.endsAt == null) { + gr.endsAt = Date.now() + lim * 1000; + } +} + +function newGauntletRunState(space) { + const crown = !!(space && isGauntletCrownHeistMapBySpace(space)); + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 3, + crownRunHeld: crown, + }; +} + +function emitGauntletSync(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + const gr = space.gauntletRun; + if (!gr) return; + const h = md.height || 15; + const obsOut = gr.obstacles.map((o) => { + if (o.kind === 'laser') { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1; + return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 }; + } + return { id: o.id, kind: o.kind, x: o.x, y: o.y }; + }); + const playersOut = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + io.to(sid).emit('gauntlet-sync', { + obstacles: obsOut, + players: playersOut, + gauntletTickMs: getGauntletTickMs(), + gauntletJumpTicks: getGauntletJumpTicks(), + gauntletTimeLimitSec: getGauntletTimeLimitSec(), + gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null, + gauntletCrownRunHeld: !!gr.crownRunHeld, + ...getGauntletVisualsForClient(), + }); +} + +function startGauntletTicker(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.timerId) return; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); +} + +function initGauntletPeersAll(space, md) { + const list = [...space.peers.values()]; + const pos = gauntletSpawnPositions(md, list.length); + const crown = isGauntletCrownHeistMapBySpace(space); + list.forEach((p, i) => { + const pt = pos[i] || pos[pos.length - 1]; + p.x = pt.x; + p.y = pt.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = crown ? 100 : 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + space.gauntletRun = newGauntletRunState(space); + if (crown) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + } + space.gauntletPreviewRowsBySocket = new Map(); +} + +function findFallbackLobbyMapForGauntletEnd() { + for (const [id, m] of maps) { + if (m && m.gameType === 'lobby') return { id, m }; + } + return null; +} + +/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */ +function endGauntletGame(sid, space, reason) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.detectiveMinigameActive) { + const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + 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 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + } + /* ประเภท 1: แยกเลน — แถวเท้า (ไม่ใช่แถวหัว; ไม่ใช่แถวใต้เท้าอีกช่อง) */ + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(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 mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(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: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(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; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(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 (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(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 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 15 }); +} + +function scheduleTroublesomeRoll(sid) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450); + if (!space.troublesomeMaxTimer) { + space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000); + } +} + +/** + * ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์ + * — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview + */ +function spaceAllowsQuizCarryLobbyRelaxed(space) { + if (!space) return false; + if (space.mapId) { + const byId = maps.get(space.mapId); + if (byId && byId.gameType === 'quiz_carry') return true; + } + const md = space.mapData; + if (md && md.gameType === 'quiz_carry') return true; + return !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); +} + +io.on('connection', (socket) => { + socket.on('join-space', ({ spaceId, nickname, characterId, playMapId }, cb) => { + const space = spaces.get(spaceId); + if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' }); + const maxPlayers = space.maxPlayers || 10; + const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' }); + const joinNickNorm = normalizeLobbyNickname(nickname); + if (space.joinLocked) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + if (!space.hostId) space.hostId = socket.id; + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = space.peers.size; + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3)); + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = [...space.peers.keys()].indexOf(socket.id); + const pos = gauntletSpawnPositions(mdJoin, space.peers.size); + const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + botSlotCount: effectiveBotSlotCount(space), + joinLocked: !!space.joinLocked, + }; + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + if (typeof cb === 'function') cb(joinCb); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + }); + + /** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */ + socket.on('preview-claim-host', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const isPreviewEditorRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' }); + space.hostId = socket.id; + io.to(sid).emit('host-changed', { hostId: socket.id }); + return reply({ ok: true }); + }); + + socket.on('troublesome-eligible', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return; + initTroublesomeState(space); + space.troublesomeEligible.add(socket.id); + scheduleTroublesomeRoll(sid); + }); + + socket.on('troublesome-response', (data) => { + const accept = !!(data && data.accept); + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return; + if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return; + if (space.troublesomeResponseReceived) return; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = accept; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + io.to(sid).emit('suspect-phase-open', { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: accept, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + socket.emit('troublesome-ack', { ok: true, accept }); + }); + + socket.on('suspect-pick-select', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return; + if (space.hostId !== socket.id) return; + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return; + space.suspectPickIndex = idx; + io.to(sid).emit('suspect-pick-update', { selectedIndex: idx }); + }); + + socket.on('suspect-pick-start', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (space && Array.isArray(space.suspectCardMinigames)) { + space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry); + } + if (!space || !serverMapIsPostCaseLobbyB(space)) { + return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' }); + } + if (!space.suspectPhaseActive) { + return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' }); + } + if (space.hostId !== socket.id) { + return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) return reply({ ok: true, alreadyOnLobbyB: true }); + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB'); + reply({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.ready = !!ready; + io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); + break; + } + } + }); + + socket.on('start-game', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id) && space.hostId === socket.id) { + let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; + let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; + if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; + if (caseId && !/^[1-3]$/.test(caseId)) caseId = ''; + ensurePostCaseLobbyMapLoaded(); + const mdBefore = getLobbyLayoutMapForSpace(space); + if (mdBefore && mdBefore.gameType === 'quiz') { + const qErr = validateQuizMap(mdBefore); + if (qErr) return reply({ ok: false, error: qErr }); + clearSpaceQuizTimers(space); + startQuizGame(sid, space, mdBefore); + io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); + return reply({ ok: true }); + } + const detectiveB = !!(data && data.detectiveLobbyBStart); + if (detectiveB && (!lobbyLevel || !caseId)) { + return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); + } + const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; + const isNamedLobbyA = mapNameNorm === 'lobbya'; + const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); + /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ + const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) + && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) + && ( + mdBefore.gameType === 'lobby' + || isNamedLobbyA + || (detectiveB && notZepOrFrogger) + )); + if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { + return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); + } + if (!lobbyHostStandingInStartArea(space, socket.id)) { + return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); + } + if (isLobbyADetectiveStart) { + assignDetectiveSuspectCardMinigames(space); + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); + const caseNickWl = new Set(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + let li = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, li++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + io.to(sid).emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + if (space.voiceInits) delete space.voiceInits[socket.id]; + if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id); + if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id]; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id); + if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') { + delete space.quizCarryLobbyReady[socket.id]; + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + } + if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { + delete space.gauntletCrownLobbyReady[socket.id]; + if (isCrownLobbyShellSpace(space)) { + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + } + space.peers.delete(socket.id); + if (space.peers.size === 0) { + stopGauntletTicker(space); + clearSpaceQuizTimers(space); + space.quizSession = null; + /* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */ + const keepCaseSpace = !!(space.detectiveMinigameActive + || (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0)); + if (keepCaseSpace) { + space.hostId = null; + return; + } + spaces.delete(sid); + } else { + if (wasHost) { + const firstPeer = space.peers.values().next().value; + if (firstPeer) { + space.hostId = firstPeer.id; + io.to(sid).emit('host-changed', { hostId: firstPeer.id }); + } + } + io.to(sid).emit('user-left', { id: socket.id }); + } + }); + + socket.on('gauntlet-jump', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return; + if ((p.gauntletJumpTicks || 0) > 0) return; + p.gauntletJumpPending = true; + }); + + socket.on('gauntlet-crown-begin-run', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const gr = space.gauntletRun; + if (!gr) return reply({ ok: false }); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space); + const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space); + if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false }); + const previewRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' }); + if (!gr.crownRunHeld) return reply({ ok: true, already: true }); + gr.crownRunHeld = false; + if (okGauntletCrown) { + ensureGauntletEndsAtIfNeeded(space); + emitGauntletSync(sid, space); + } else { + emitBalloonBossMissionShellGauntletSync(sid, space); + } + return reply({ ok: true }); + }); + + /** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */ + socket.on('gauntlet-preview-rows', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + const hh = md.height || 15; + if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map(); + const raw = data && Array.isArray(data.ys) ? data.ys : []; + const set = new Set(); + const cap = Math.min(raw.length, 48); + for (let i = 0; i < cap; i++) { + const fy = Math.floor(Number(raw[i])); + if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy); + } + space.gauntletPreviewRowsBySocket.set(socket.id, set); + }); + + socket.on('quiz-carry-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') { + space.quizCarryLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + socket.emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + space.quizCarryLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const rm = space.quizCarryLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('quiz-carry-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('gauntlet-crown-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') { + space.gauntletCrownLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + socket.emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const gr = space.gauntletRun; + if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' }); + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('gauntlet-crown-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('move', (data) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (md && md.gameType === 'gauntlet') { + const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId }; + socket.emit('user-move', out); + break; + } + let nx = data.x; + let ny = data.y; + const sess = space.quizSession; + const mdQuiz = (sess && sess.quizMapMd) || md; + if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') { + const st = sess.players && sess.players[socket.id]; + if (st && !st.eliminated) { + const tx = Math.floor(Number(nx)); + const ty = Math.floor(Number(ny)); + const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty); + const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty); + if (blockTrue || blockFalse) { + nx = p.x; + ny = p.y; + } + } + } + if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) { + const txN = Number(nx); + const tyN = Number(ny); + if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + } + if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const txN = Number(nx); + const tyN = Number(ny); + if (!Number.isFinite(txN) || !Number.isFinite(tyN)) { + nx = p.x; + ny = p.y; + } else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny)); + nx = sn.x; + ny = sn.y; + } + if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) { + const ns = Math.floor(Number(data.spaceShooterScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.spaceShooterScore | 0); + if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns); + } + } + if (p && md && md.gameType === 'balloon_boss' && data) { + const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + if (data.balloonBossScore != null) { + const ns = Math.floor(Number(data.balloonBossScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.balloonBossScore | 0); + if (ns <= prev + 35) p.balloonBossScore = Math.max(prev, ns); + } + } + if (data.balloonBossBossDmg != null) { + const nd = Math.floor(Number(data.balloonBossBossDmg)); + if (Number.isFinite(nd) && nd >= 0) { + const prevD = Math.max(0, p.balloonBossBossDmg | 0); + if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd); + } + } + if (data.balloonBossBalloons != null) { + const nb = Math.floor(Number(data.balloonBossBalloons)); + if (Number.isFinite(nb) && nb >= 0) { + const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.floor(p.balloonBossBalloons) + : bbDefaultBalloons; + if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb; + } + } + if (data.balloonBossEliminated) p.balloonBossEliminated = true; + } + if (p) { + p.x = nx; + p.y = ny; + p.direction = data.direction || p.direction; + const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId }; + if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0); + if (md && md.gameType === 'balloon_boss') { + const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + out.balloonBossScore = Math.max(0, p.balloonBossScore | 0); + out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0); + out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.max(0, Math.floor(p.balloonBossBalloons)) + : bbDef; + out.balloonBossEliminated = !!p.balloonBossEliminated; + } + if (nx !== data.x || ny !== data.y) socket.emit('user-move', out); + socket.to(sid).emit('user-move', out); + } + break; + } + } + }); + + socket.on('lobby-interact', (data, ack) => { + const reply = typeof ack === 'function' ? ack : () => {}; + const tx = Math.floor(Number(data && data.x)); + const ty = Math.floor(Number(data && data.y)); + for (const [sid, space] of spaces) { + if (!space.peers.has(socket.id)) continue; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' }); + const w = md.width || 20; + const h = md.height || 15; + if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) { + return reply({ ok: false, error: 'จุดไม่ถูกต้อง' }); + } + if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) { + return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' }); + } + const px = Math.floor(Number(p.x)); + const py = Math.floor(Number(p.y)); + const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]]; + let near = false; + for (const [dx, dy] of neighbors) { + if (px + dx === tx && py + dy === ty) { near = true; break; } + } + if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' }); + if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' }); + const nick = (p.nickname || '').trim() || 'ผู้เล่น'; + io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty }); + return reply({ ok: true }); + } + reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' }); + }); + + socket.on('chat', (text) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + const nick = space.peers.get(socket.id)?.nickname || ''; + io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick }); + break; + } + } + }); + + socket.on('voice-state', ({ micOn }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.voiceMicOn = micOn !== false; + if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id]; + io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false }); + break; + } + } + }); + + socket.on('voice-activity', ({ level }) => { + const sid = socket.data.spaceId; + if (!sid || !spaces.get(sid)) return; + const until = Date.now() + 400; + io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until }); + }); + + socket.on('voice-init', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + if (!space.voiceInits) space.voiceInits = {}; + space.voiceInits[socket.id] = data; + io.to(sid).emit('voice-init', { from: socket.id, data }); + break; + } + } + }); + + socket.on('voice-chunk', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + socket.to(sid).emit('voice-chunk', { from: socket.id, data }); + break; + } + } + }); + + socket.on('webrtc-signal', (data) => { + const { to, type, sdp, candidate } = data; + if (!to) return; + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid) && space.peers.has(to)) { + io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate }); + break; + } + } + }); +}); + +loadMaps(); +server.listen(PORT, () => console.log('Game', PORT)); \ No newline at end of file diff --git a/www/html/Game/server.js.bak-20260521-234519 b/www/html/Game/server.js.bak-20260521-234519 new file mode 100644 index 0000000..c513883 --- /dev/null +++ b/www/html/Game/server.js.bak-20260521-234519 @@ -0,0 +1,5064 @@ +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { Server } = require('socket.io'); + +const PORT = process.env.PORT || 3001; +const BASE_PATH = '/Game'; +const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json'); +const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin'; +const adminTokens = new Set(); +/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */ +const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; +/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */ +const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; +/** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */ +/** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */ +const DETECTIVE_MINIGAME_POOL = [ + { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' }, + { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' }, + { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' }, + { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' }, + { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' }, + { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' }, + { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' }, +]; +const LOBBY_SLOT_TOTAL = 6; + +/** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */ +function effectiveBotSlotCount(space) { + let n = parseInt(space && space.botSlotCount, 10); + if (isNaN(n) || n < 0) n = 0; + const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0; + if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) { + n = LOBBY_SLOT_TOTAL - maxP; + if (space) space.botSlotCount = n; + } + return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n)); +} +/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */ +const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; +/** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */ +const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; +/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */ +const SPACE_EMPTY_TTL_MS = 45000; +// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025) +const AI_MODELS = [ + 'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano', + 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', + 'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research', + 'o1', 'o1-mini', 'o1-pro', + 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', + 'gpt-3.5-turbo', +]; + +function loadAiSettings() { + try { + if (fs.existsSync(AI_SETTINGS_PATH)) { + const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8'); + return JSON.parse(raw); + } + } catch (e) { console.error('loadAiSettings', e.message); } + return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' }; +} + +function saveAiSettings(settings) { + try { + const dir = path.dirname(AI_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); + } catch (e) { console.error('saveAiSettings', e.message); } +} + +function getAdminToken(cookieHeader) { + if (!cookieHeader) return null; + const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/); + return m && adminTokens.has(m[1]) ? m[1] : null; +} + +const MAPS_DIR = path.join(__dirname, 'data', 'maps'); +const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters'); +/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */ +const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; +const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right']; + +/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */ +function buildCharacterLayerManifest(id, files) { + if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} }; + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|'); + const byDir = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiHits = files.filter((f) => multiRe.test(f)); + if (multiHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiHits.forEach((f) => { + const m = f.match(multiRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDir[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return; + const m = f.match(singleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] }; + } + } + const byDirIdle = {}; + for (const dir of CHAR_MANIFEST_DIRS) { + const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const multiIdleHits = files.filter((f) => multiIdleRe.test(f)); + if (multiIdleHits.length) { + let maxFi = -1; + const perFrame = new Map(); + multiIdleHits.forEach((f) => { + const m = f.match(multiIdleRe); + if (!m) return; + const fi = parseInt(m[1], 10); + if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; + const layer = m[2]; + if (fi > maxFi) maxFi = fi; + if (!perFrame.has(fi)) perFrame.set(fi, {}); + perFrame.get(fi)[layer] = f; + }); + const frames = []; + for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); + byDirIdle[dir] = { multi: true, frames }; + } else { + const frame0 = {}; + files.forEach((f) => { + if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return; + const m = f.match(singleIdleRe); + if (m) frame0[m[1]] = f; + }); + if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] }; + } + } + return { ok: true, id, byDir, byDirIdle }; +} + +/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */ +const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets'); +const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets'); +const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); +/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */ +const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets'); +const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json'); +/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */ +const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH + ? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim()) + : ''; +const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json'); +/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ +const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; +const maps = new Map(); +const defaultMap = () => ({ + width: 20, height: 15, tileSize: 32, gameType: 'zep', + characterCells: 1, characterCellsW: 1, characterCellsH: 1, + /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ + blockPlayerSeparationW: 0, + blockPlayerSeparationH: 0, + ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, + gridImageLibrary: [], + gridImageSprites: [], + gridImageCells: [], + quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], + quizCarryHubArea: [], quizCarryOptionArea: [], + /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ + carryEmbedCountdownArea: [], + /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ + quizBattleDomeArea: [], + /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ + quizBattlePathArea: [], + stackReleaseArea: [], stackLandArea: [], + /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ + jumpSurvivePlatforms: [], + jumpSurvivePlatformArea: [], + /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ + jumpSurvivePlatformVariantArea: [], + jumpSurviveHazardArea: [], + jumpSurviveRisePxPerSec: 42, + jumpSurviveGravity: 3200, + /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ + jumpSurviveJumpImpulse: 0, + jumpSurviveMovePxPerSec: 200, +}); + +function defaultQuizSettings() { + return { + readMs: 10000, + answerMs: 5000, + betweenMs: 3500, + /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ + carryReadMs: 3000, + /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ + carryAnswerMs: 5000, + /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ + carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), + /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ + quizMapPanelTheme: defaultCarryMapPanelTheme(), + /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ + carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), + /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 1–16 */ + carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(), + /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */ + carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(), + /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */ + carryChoicePlaqueMapScale: 1.25, + /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */ + carryWalkSpeedMultForMapId: '', + carryWalkSpeedMult: null, + /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */ + quizRoundQuestionCount: 10, + questions: [], + carryQuestions: [], + battleQuizMcq: [], + }; +} + +function defaultCarryMapPanelTheme() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + questionFontMinPx: 10, + questionFontMaxPx: 24, + }; +} + +function sanitizeCssColorToken(input, fallback) { + const t = String(input == null ? '' : input).trim().slice(0, 120); + if (!t) return fallback; + if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback; + 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)) { + const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, ''); + if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim(); + } + return fallback; +} + +function sanitizeCarryMapPanelTheme(raw) { + const d = defaultCarryMapPanelTheme(); + if (!raw || typeof raw !== 'object') return d; + let qMin = Number(raw.questionFontMinPx); + let qMax = Number(raw.questionFontMaxPx); + if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx; + if (!Number.isFinite(qMax)) qMax = d.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 t = qMin; + qMin = qMax; + qMax = t; + } + return { + panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg), + panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder), + borderWidthPx: (() => { + let w = Number(raw.borderWidthPx); + if (!Number.isFinite(w)) w = d.borderWidthPx; + return Math.max(0, Math.min(12, Math.round(w))); + })(), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; +} + +/** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */ +function defaultCarryChoicePlaqueTheme() { + 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, + }; +} + +function sanitizeCarryChoicePlaqueTheme(raw) { + const d = defaultCarryChoicePlaqueTheme(); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({}); + 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: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder), + fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + borderWidthPx: bw, + plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl), + }; +} + +const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16; + +function defaultCarryChoicePlaqueThemes() { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme({})); + } + return out; +} + +/** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */ +function sanitizeCarryChoicePlaqueThemes(raw) { + if (Array.isArray(raw) && raw.length > 0) { + const out = []; + for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { + out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {})); + } + return out; + } + if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { + const one = sanitizeCarryChoicePlaqueTheme(raw); + return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one })); + } + return defaultCarryChoicePlaqueThemes(); +} + +function sanitizeCarryChoiceImageUrl(input) { + const s = String(input == null ? '' : input).trim().slice(0, 512); + if (!s) return ''; + if (/[\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 u = new URL(s); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + return s; + } catch (e) { + return ''; + } +} + +function defaultCarryEmbedCountdownTheme() { + 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 sanitizeCarryEmbedCountdownTheme(raw) { + const d = defaultCarryEmbedCountdownTheme(); + 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))); + }; + return { + overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop), + innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg), + innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder), + innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), + innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), + digitColor: sanitizeCssColorToken(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), + }; +} + +/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */ +const QUIZ_BATTLE_CATEGORY_IDS = new Set([ + 'cybercrime', + 'cyber_law', + 'privacy', + 'digital_security', + 'social_media', + 'ethics', + 'ai_data', +]); + +/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */ +function sanitizeBattleQuizMcq(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + let categoryId = String(q.categoryId || '').trim(); + if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) { + categoryId = 'cybercrime'; + } + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + const rawChoices = Array.isArray(q.choices) ? q.choices : []; + const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160)); + if (choices.length !== 3 || !choices.every(Boolean)) continue; + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(2, Math.floor(ci))); + out.push({ categoryId, text, choices, correctIndex: ci }); + if (out.length >= 200) break; + } + return out; +} + +/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */ +function sanitizeCarryQuestions(arr) { + if (!Array.isArray(arr)) return []; + const out = []; + for (const q of arr) { + if (!q || typeof q !== 'object') continue; + const text = String(q.text || '').trim().slice(0, 500); + if (!text) continue; + let choices = Array.isArray(q.choices) + ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean) + : []; + if (choices.length < 2) continue; + if (choices.length > 16) choices = choices.slice(0, 16); + let ci = Number(q.correctIndex); + if (!Number.isFinite(ci)) ci = 0; + ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci))); + const row = { text, choices, correctIndex: ci }; + if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { + const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx])); + if (urls.some((u) => u)) row.choiceImageUrls = urls; + } + out.push(row); + if (out.length >= 80) break; + } + return out; +} + +function clampQuizMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(1000, Math.min(300000, Math.floor(v))); +} + +function clampQuizBetweenMs(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(300000, Math.floor(v))); +} + +function clampCarrySessionLength(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.max(0, Math.min(500, Math.floor(v))); +} + +/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */ +function clampQuizRoundQuestionCount(n, def) { + const v = Number(n); + if (!Number.isFinite(v)) { + const d = Number(def); + if (!Number.isFinite(d)) return 10; + return Math.max(1, Math.min(50, Math.floor(d))); + } + return Math.max(1, Math.min(50, Math.floor(v))); +} + +function clampCarryChoicePlaqueMapScale(n, def) { + const v = Number(n); + if (Number.isNaN(v)) return def; + return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100; +} + +/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */ +function sanitizeCarryWalkSpeedMultForMapId(raw) { + const t = String(raw == null ? '' : raw).trim().slice(0, 64); + if (!t) return ''; + if (!/^[a-zA-Z0-9_-]+$/.test(t)) return ''; + return t; +} + +/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */ +function clampCarryWalkSpeedMultSetting(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v)) return null; + return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; +} + +function mergeQuizCarryFromMirrorIfSet(base) { + if (!QUIZ_SETTINGS_MIRROR_PATH) return base; + try { + if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base; + const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8'); + const j = JSON.parse(raw); + if (!j || typeof j !== 'object') return base; + if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') { + base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + } + if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') { + base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + } + if (j.carryReadMs != null) { + base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs); + } + if (j.carryAnswerMs != null) { + base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs); + } + if (j.carrySessionLength != null) { + base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength); + } + if (j.carryWalkSpeedMultForMapId != null) { + base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + } + if (j.carryWalkSpeedMult != null) { + const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (wm != null) base.carryWalkSpeedMult = wm; + } + if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') { + base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + } + if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') { + base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; + } + if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) { + base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + } + if (j.quizRoundQuestionCount != null) { + base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount); + } + return base; + } catch (e) { + console.error('mergeQuizCarryFromMirrorIfSet', e.message); + return base; + } +} + +function loadQuizSettings() { + try { + if (fs.existsSync(QUIZ_SETTINGS_PATH)) { + const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8')); + const d = defaultQuizSettings(); + const questions = Array.isArray(j.questions) ? j.questions : []; + const carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq); + const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); + const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); + const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length + ? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes) + : sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale); + const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); + let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); + if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null; + else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + const out = { + readMs: clampQuizMs(j.readMs, d.readMs), + answerMs: clampQuizMs(j.answerMs, d.answerMs), + betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs), + carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs), + carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs), + carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength), + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount), + questions, + carryQuestions, + battleQuizMcq, + }; + return mergeQuizCarryFromMirrorIfSet(out); + } + } catch (e) { console.error('loadQuizSettings', e.message); } + return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings()); +} + +const GAUNTLET_DEFAULT_TICK_MS = 220; +const GAUNTLET_DEFAULT_JUMP_TICKS = 16; +const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0; +/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */ +const STACK_SWING_HZ_DEFAULT = 0.55; + +function defaultGameTiming() { + return { + gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS, + gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS, + gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC, + ...defaultGauntletVisuals(), + stackSwingHz: STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: null, + /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */ + jumpSurviveJumpHeightMult: 1.5, + /** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */ + jumpSurviveMissionTimeSec: 0, + /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ + jumpSurvivePlatformTiles: [ + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + { url: '', w: 0, h: 0 }, + ], + /** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */ + stackTowerMissionTimeSec: 90, + /** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */ + stackTeamMissesMax: 3, + /** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ + stackTowerProgressBlocks: 50, + stackBlockNormalImageUrls: ['', '', '', '', '', ''], + stackBlockHeavyImageUrls: ['', '', '', '', '', ''], + /** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */ + stackHeavyBlockPercent: 35, + /** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */ + spaceShooterMissionTimeSec: 0, + spaceShooterShipImageUrls: ['', '', '', '', '', ''], + spaceShooterAsteroidSpriteUrls: [], + spaceShooterAsteroidExplodeFrameMs: 70, + /** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ + spaceShooterAsteroidIntervalMs: 1040, + spaceShooterShipDamageOverlayUrls: ['', '', ''], + /** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */ + balloonBossMissionTimeSec: 0, + balloonBossBossImageUrl: '', + balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''], + /** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */ + balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png', + }; +} + +function clampStackSwingHz(n) { + const v = Number(n); + if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT; + return Math.max(0.08, Math.min(2.8, v)); +} + +/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */ +function clampStackBlockWidthTiles(n) { + if (n === null || n === undefined || n === '') return null; + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return null; + return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100; +} + +function clampStackTowerMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 90; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function clampStackTeamMissesMax(n) { + const v = Number(n); + if (Number.isNaN(v)) return 3; + return Math.max(1, Math.min(20, Math.floor(v))); +} + +function clampStackTowerProgressBlocks(n) { + const v = Number(n); + if (Number.isNaN(v)) return 50; + return Math.max(1, Math.min(500, Math.floor(v))); +} + +/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */ +function normalizeStackBlockSixImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +function clampStackHeavyBlockPercent(n) { + const v = Number(n); + if (Number.isNaN(v)) return 35; + return Math.max(0, Math.min(100, Math.floor(v))); +} + +function clampJumpSurviveJumpHeightMult(n) { + const v = Number(n); + if (Number.isNaN(v)) return 1.5; + return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100; +} + +/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 10–7200 */ +function clampJumpSurviveMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */ +function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = []; + } + } + if (!Array.isArray(arr)) arr = []; + const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : ''; + const out = []; + for (let i = 0; i < 3; i++) { + const o = arr[i]; + const obj = o && typeof o === 'object' ? o : {}; + let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : ''); + if (i === 0 && !url && legacy) url = legacy; + let ww = Number(obj.w); + let hh = Number(obj.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))); + out.push({ url, w: ww, h: hh }); + } + return out; +} + +function clampSpaceShooterMissionTimeSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +/** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */ +function normalizeSpaceShooterShipImageUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < 6; i++) { + out.push(sanitizeGauntletAssetUrl(arr[i])); + } + return out; +} + +/** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */ +function normalizeSpaceShooterAsteroidSpriteUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = []; + for (let i = 0; i < arr.length && out.length < 32; i++) { + const u = sanitizeGauntletAssetUrl(arr[i]); + if (u) out.push(u); + } + return out; +} + +function clampSpaceShooterAsteroidExplodeFrameMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return 70; + return Math.max(30, Math.min(500, Math.round(v))); +} + +const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040; +function clampSpaceShooterAsteroidIntervalMs(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + return Math.max(200, Math.min(10000, Math.floor(v))); +} + +/** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */ +function normalizeSpaceShooterShipDamageOverlayUrls(val) { + let arr = val; + if (typeof arr === 'string') { + try { + const p = JSON.parse(arr); + if (Array.isArray(p)) arr = p; + } catch (e) { + arr = arr.split(/[\n\r]+/); + } + } + if (!Array.isArray(arr)) arr = []; + const out = ['', '', '']; + for (let i = 0; i < 3; i++) { + out[i] = sanitizeGauntletAssetUrl(arr[i]); + } + return out; +} + +function clampGauntletTickMs(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS; + return Math.max(80, Math.min(800, Math.floor(v))); +} + +function clampGauntletJumpTicks(n) { + const v = Number(n); + if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS; + return Math.max(4, Math.min(40, Math.floor(v))); +} + +/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */ +function clampGauntletTimeLimitSec(n) { + const v = Number(n); + if (Number.isNaN(v) || v <= 0) return 0; + return Math.max(10, Math.min(7200, Math.floor(v))); +} + +function defaultGauntletVisuals() { + return { + gauntletLaneImageUrls: [], + gauntletLaserTopUrl: '', + gauntletLaserBottomUrl: '', + gauntletLaserLineUrl: '', + gauntletLaserFillColor: 'rgba(140,230,255,0.42)', + gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)', + gauntletLaserLineWidthPx: 2, + }; +} + +/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */ +function encodeSpacesInAssetUrlPath(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; +} + +/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */ +function normalizeGameAssetUrlForWeb(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; +} + +/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */ +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; +} + +/** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */ +function fixBareMegaVirusArtboardUrl(t) { + const s = String(t || '').trim().replace(/\/+$/, ''); + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; + return t; +} + +function sanitizeGauntletAssetUrl(s) { + const t = fixBareMegaVirusArtboardUrl( + normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '') + ); + if (!t || t.length > 500) return ''; + /** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */ + if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return ''; + /** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */ + if (/\.{4,}/.test(t)) return ''; + if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t); + if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t); + /** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */ + if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`); + return ''; +} + +function clampGauntletLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t) return def; + if (/[<>"'`]/.test(t)) return def; + return t; +} + +function clampGauntletLaserLineWidthPx(n) { + const v = Number(n); + if (Number.isNaN(v)) return 2; + return Math.max(0, Math.min(24, Math.round(v))); +} + +function normalizeGauntletVisualsFromRequest(d) { + const def = defaultGauntletVisuals(); + if (!d || typeof d !== 'object') return { ...def }; + let laneArr = d.gauntletLaneImageUrls; + if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/); + if (!Array.isArray(laneArr)) laneArr = []; + const gauntletLaneImageUrls = []; + for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) { + const u = sanitizeGauntletAssetUrl(laneArr[i]); + if (u) gauntletLaneImageUrls.push(u); + } + return { + gauntletLaneImageUrls, + gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl), + gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl), + gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl), + gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor), + gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor), + gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx), + }; +} + +function getGauntletVisualsForClient() { + const r = runtimeGameTiming; + const d = defaultGauntletVisuals(); + return { + gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls, + gauntletLaserTopUrl: r.gauntletLaserTopUrl || '', + gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '', + gauntletLaserLineUrl: r.gauntletLaserLineUrl || '', + gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor, + gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor, + gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx, + }; +} + +function loadGameTiming() { + try { + if (fs.existsSync(GAME_TIMING_PATH)) { + const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8')); + const vis = normalizeGauntletVisualsFromRequest(j); + return { + gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec), + ...vis, + stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz') + ? clampStackSwingHz(j.stackSwingHz) + : STACK_SWING_HZ_DEFAULT, + stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(j.stackBlockWidthTiles) + : null, + stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec) + : 90, + stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(j.stackTeamMissesMax) + : 3, + stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks) + : 50, + stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls), + stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls), + stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent) + : 35, + jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult) + : 1.5, + jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec) + : 0, + jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming( + j.jumpSurvivePlatformTiles, + j.jumpSurvivePlatformTileUrl, + ), + spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec) + : 0, + spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls), + spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls), + spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs) + : 70, + spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs) + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT, + spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls), + balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec) + : 0, + balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl) + : '', + balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls), + balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl) + : '', + }; + } + } catch (e) { console.error('loadGameTiming', e.message); } + return defaultGameTiming(); +} + +let runtimeGameTiming = loadGameTiming(); + +function getGauntletTickMs() { + return runtimeGameTiming.gauntletTickMs; +} + +function getGauntletJumpTicks() { + return runtimeGameTiming.gauntletJumpTicks; +} + +function getGauntletTimeLimitSec() { + return runtimeGameTiming.gauntletTimeLimitSec || 0; +} + +function saveGameTimingToFile(d) { + try { + const dir = path.dirname(GAME_TIMING_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const prev = { ...runtimeGameTiming }; + const vis = normalizeGauntletVisualsFromRequest(d); + const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz') + ? clampStackSwingHz(d.stackSwingHz) + : clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT); + const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles') + ? clampStackBlockWidthTiles(d.stackBlockWidthTiles) + : (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles') + ? prev.stackBlockWidthTiles + : null); + const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult') + ? prev.jumpSurviveJumpHeightMult + : 1.5; + const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult') + ? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult) + : clampJumpSurviveJumpHeightMult(prevMult); + const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec') + ? prev.jumpSurviveMissionTimeSec + : 0; + const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec') + ? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec) + : clampJumpSurviveMissionTimeSec(prevJumpMissionSec); + const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming( + prev.jumpSurvivePlatformTiles, + prev.jumpSurvivePlatformTileUrl, + ); + let jumpSurvivePlatformTiles; + if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + d.jumpSurvivePlatformTiles, + d.jumpSurvivePlatformTileUrl, + ); + } else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) { + jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( + [ + { url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 }, + prevJumpTiles[1], + prevJumpTiles[2], + ], + '', + ); + } else { + jumpSurvivePlatformTiles = prevJumpTiles; + } + const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec') + ? prev.spaceShooterMissionTimeSec + : 0; + const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec); + const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls) + ? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls) + : normalizeSpaceShooterShipImageUrls([]); + const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls') + ? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls) + : prevShipUrls; + const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls) + ? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls) + : []; + const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls') + ? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls) + : prevAstUrls; + const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs') + ? prev.spaceShooterAsteroidExplodeFrameMs + : 70; + const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs') + ? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs) + : clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms); + const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs') + ? prev.spaceShooterAsteroidIntervalMs + : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; + const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs') + ? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs) + : clampSpaceShooterAsteroidIntervalMs(prevAstIv); + const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls) + ? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls) + : normalizeSpaceShooterShipDamageOverlayUrls([]); + const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls') + ? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls) + : prevDmg; + const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec') + ? prev.balloonBossMissionTimeSec + : 0; + const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec') + ? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec) + : clampSpaceShooterMissionTimeSec(prevBbMission); + const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl) + : ''; + const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl) + : prevBbBoss; + const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls) + ? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls) + : normalizeStackBlockSixImageUrls([]); + const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls') + ? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls) + : prevBbBalloons; + const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl) + : ''; + const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl') + ? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl) + : prevBbFb; + const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec') + ? prev.stackTowerMissionTimeSec + : 90; + const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec') + ? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec) + : clampStackTowerMissionTimeSec(prevStackTowerSec); + const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax') + ? prev.stackTeamMissesMax + : 3; + const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax') + ? clampStackTeamMissesMax(d.stackTeamMissesMax) + : clampStackTeamMissesMax(prevStackMisses); + const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks') + ? prev.stackTowerProgressBlocks + : 50; + const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks') + ? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks) + : clampStackTowerProgressBlocks(prevStackProgBlocks); + const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls) + : normalizeStackBlockSixImageUrls([]); + const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls) + ? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls) + : normalizeStackBlockSixImageUrls([]); + const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls) + : prevStackNorm; + const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls') + ? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls) + : prevStackHeavy; + const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent) + : 35; + const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent') + ? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent) + : prevHeavyPct; + const out = { + gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs), + gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks), + gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec), + ...vis, + stackSwingHz: stackHz, + stackBlockWidthTiles: stackBlockW, + stackTowerMissionTimeSec, + stackTeamMissesMax, + stackTowerProgressBlocks, + stackBlockNormalImageUrls, + stackBlockHeavyImageUrls, + stackHeavyBlockPercent, + jumpSurviveJumpHeightMult: jumpMult, + jumpSurviveMissionTimeSec: jumpMissionSec, + jumpSurvivePlatformTiles, + spaceShooterMissionTimeSec: spaceShooterMissionSec, + spaceShooterShipImageUrls, + spaceShooterAsteroidSpriteUrls, + spaceShooterAsteroidExplodeFrameMs, + spaceShooterAsteroidIntervalMs, + spaceShooterShipDamageOverlayUrls, + balloonBossMissionTimeSec, + balloonBossBossImageUrl, + balloonBossPlayerBalloonImageUrls, + balloonBossPlayerBalloonFallbackUrl, + }; + fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); + runtimeGameTiming = out; + rescheduleAllGauntletTickers(); + return { ok: true, ...out }; + } catch (e) { + console.error('saveGameTimingToFile', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + +function ensureGauntletAssetsDir() { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); +} + +function loadGauntletAssetsMeta() { + try { + if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { + const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); + return j && typeof j === 'object' ? j : {}; + } + } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } + return {}; +} + +function saveGauntletAssetsMeta(meta) { + const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); +} + +function safeGauntletStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function safeQuizCarryPlaqueStoredFilename(name) { + const base = path.basename(String(name || '')); + if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; + return base; +} + +function ensureQuizCarryPlaqueAssetsDir() { + if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); +} + +function gauntletAssetMimeByExt(ext) { + const e = String(ext || '').toLowerCase(); + if (e === '.png') return 'image/png'; + if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; + if (e === '.gif') return 'image/gif'; + if (e === '.webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function listGauntletAssetsApi() { + ensureGauntletAssetsDir(); + const meta = loadGauntletAssetsMeta(); + let files = []; + try { + files = fs.readdirSync(GAUNTLET_ASSETS_DIR); + } catch (e) { + return []; + } + return files + .filter((f) => safeGauntletStoredFilename(f)) + .map((f) => { + const fp = path.join(GAUNTLET_ASSETS_DIR, f); + let st; + try { + st = fs.statSync(fp); + } catch (e) { + return null; + } + const entry = meta[f]; + const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; + return { + filename: f, + url: BASE_PATH + '/img/gauntlet-assets/' + f, + label: label || f, + bytes: st.size, + mtime: st.mtimeMs, + }; + }) + .filter(Boolean) + .sort((a, b) => b.mtime - a.mtime); +} + +function saveQuizSettings(d) { + try { + const prev = loadQuizSettings(); + const dir = path.dirname(QUIZ_SETTINGS_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; + const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; + const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; + const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; + const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; + const carrySessionLength = d.carrySessionLength != null + ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) + : prev.carrySessionLength; + const questions = Array.isArray(d.questions) + ? (d.questions || []) + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) + : prev.questions; + const carryQuestions = Array.isArray(d.carryQuestions) + ? sanitizeCarryQuestions(d.carryQuestions) + : prev.carryQuestions; + const battleQuizMcq = Array.isArray(d.battleQuizMcq) + ? sanitizeBattleQuizMcq(d.battleQuizMcq) + : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; + const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) + : prev.quizMapPanelTheme; + const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' + ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) + : prev.carryEmbedCountdownTheme; + let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; + if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); + } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); + } + if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { + carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); + } + const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; + const prevScale = prev.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) + : 1.25; + const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null + ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) + : prevScale; + const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); + const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; + let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined + ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) + : prevWalkId; + let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; + if (!carryWalkSpeedMultForMapId) { + carryWalkSpeedMult = null; + } else if (carryWalkSpeedMult == null) { + carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); + } + const quizRoundQuestionCount = d.quizRoundQuestionCount != null + ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) + : prev.quizRoundQuestionCount; + const out = { + readMs, + answerMs, + betweenMs, + carryReadMs, + carryAnswerMs, + carrySessionLength, + carryMapPanelTheme, + quizMapPanelTheme, + carryEmbedCountdownTheme, + carryChoicePlaqueThemes, + carryChoicePlaqueTheme, + carryChoicePlaqueMapScale, + carryWalkSpeedMultForMapId, + carryWalkSpeedMult, + quizRoundQuestionCount, + questions, + carryQuestions, + battleQuizMcq, + }; + fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); + return { + ok: true, + battleQuizMcqSaved: (out.battleQuizMcq || []).length, + carryQuestionsSaved: (out.carryQuestions || []).length, + carryMapPanelTheme: out.carryMapPanelTheme, + quizMapPanelTheme: out.quizMapPanelTheme, + carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, + }; + } catch (e) { + console.error('saveQuizSettings', e.message); + let err = 'บันทึกไม่ได้'; + if (e && e.code === 'EACCES') { + err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; + } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; + return { ok: false, error: err }; + } +} + +function getQuizQuestionPool(md) { + const g = loadQuizSettings(); + const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); + if (globalQ.length) return globalQ; + return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); +} + +function quizCellOn(grid, tx, ty) { + return !!(grid && grid[ty] && grid[ty][tx] === 1); +} + +function getCharacterFootprintWHForMove(md) { + 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 }; +} + +function getCharacterCollisionFootprintWHForMove(md) { + const { cw, ch } = getCharacterFootprintWHForMove(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 serverWallCollisionTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const offX = minTx + Math.floor((cw - colW) / 2); + const offY = minTy + (ch - colH); + const out = []; + 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) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintTileKeys(md, px, py) { + const w = md.width || 20; + const h = md.height || 15; + const { cw, ch } = getCharacterFootprintWHForMove(md); + const minTx = Math.floor(Number(px)); + const minTy = Math.floor(Number(py)); + const maxTx = Math.min(w - 1, minTx + cw - 1); + const maxTy = Math.min(h - 1, minTy + ch - 1); + const out = []; + for (let ty = minTy; ty <= maxTy; ty++) { + for (let tx = minTx; tx <= maxTx; tx++) { + if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); + } + } + return out; +} + +function quizCarryFootprintOverlapsHubServer(md, px, py) { + if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; + for (const k of quizCarryFootprintTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; + } + return false; +} + +/** Nearest walkable tile center not in true/false answer zones (BFS). */ +function findNearestOutsideQuizAnswerZones(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) => quizCellOn(tGrid, x, y) || quizCellOn(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 validateQuizMap(md) { + const pool = getQuizQuestionPool(md); + if (pool.length < 1) { + return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; + } + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + let anyT = false; + let anyF = false; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (quizCellOn(tGrid, x, y)) anyT = true; + if (quizCellOn(fGrid, x, y)) anyF = true; + } + } + if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; + return null; +} + +function clearSpaceQuizTimers(space) { + if (!space.quizTimers || !Array.isArray(space.quizTimers)) { + space.quizTimers = []; + return; + } + space.quizTimers.forEach((t) => clearTimeout(t)); + space.quizTimers = []; +} + +/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ +const QUIZ_TF_POINTS_PER_CORRECT = 10; + +function shuffleQuizQuestions(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; +} + +function buildPeersSnapForSpace(space, extraFields) { + return [...space.peers.values()].map((p) => { + const row = { + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + }; + if (extraFields && extraFields.gauntlet) { + row.gauntletJumpTicks = p.gauntletJumpTicks || 0; + row.gauntletScore = p.gauntletScore || 0; + row.gauntletEliminated = !!p.gauntletEliminated; + } + return row; + }); +} + +/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ +/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ +function normalizeDetectiveCardEntry(entry) { + if (!entry) return entry; + const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; + if (mid === 'mlpecp2j' || entry.key === 'frogger') { + const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); + if (gaunt && maps.has(gaunt.mapId)) { + const md = maps.get(gaunt.mapId); + return { + ...entry, + key: gaunt.key, + mapId: gaunt.mapId, + labelTh: gaunt.labelTh, + mapName: (md && md.name) || gaunt.mapId, + gameType: (md && md.gameType) || 'gauntlet', + }; + } + } + return entry; +} + +function assignDetectiveSuspectCardMinigames(space) { + const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); + const shuffled = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); + space.suspectCardMinigames = shuffled.slice(0, 3).map((entry, i) => { + const md = maps.get(entry.mapId); + return { + cardIndex: i, + key: entry.key, + mapId: entry.mapId, + labelTh: entry.labelTh, + mapName: (md && md.name) || entry.mapId, + gameType: (md && md.gameType) || '', + }; + }); + // ชุดการ์ดใหม่ = เริ่มนับหลักฐาน (ติกถูก) ใหม่ทั้งหมด + space.suspectProgress = [0, 0, 0]; + // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) + space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = null; // null | 'voting' | 'revealed' + space.trialVotes = {}; // socketId -> suspectIndex + space.trialScores = space.trialScores || {}; +} + +/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ +function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { + const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; + const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; + if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; + + space.suspectPhaseActive = false; + space.detectiveMinigameActive = true; + space.detectiveMinigameCardIndex = cardEntry.cardIndex; + // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ + space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) + ? selectedIndex + : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); + space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.mapId = mapId; + space.mapData = md; + + let si = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(md, si++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + + const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); + const invPayload = { + selectedIndex, + cardIndex: cardEntry.cardIndex, + minigameKey: cardEntry.key, + minigameLabel: cardEntry.labelTh, + mapId, + }; + io.to(sid).emit('suspect-investigation-start', invPayload); + + if (md.gameType === 'quiz') { + const qErr = validateQuizMap(md); + if (qErr) { + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + return { ok: false, error: qErr }; + } + io.to(sid).emit('game-start', { + quizMode: true, + stayInRoomLobby: true, + mapId, + peersSnap, + detectiveMinigame: true, + minigameLabel: cardEntry.labelTh, + }); + setTimeout(() => { + const spNow = spaces.get(sid); + if (!spNow || !spNow.peers.size) return; + const mdLive = maps.get(mapId); + if (!mdLive || mdLive.gameType !== 'quiz') return; + startQuizGame(sid, spNow, mdLive); + }, 900); + return { ok: true, mapId, gameType: md.gameType }; + } + + stopGauntletTicker(space); + space.gauntletRun = null; + if (md.gameType === 'gauntlet') { + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + + const gameStartPayload = { + mapId, + peersSnap, + detectiveMinigame: true, + detectiveReturnLobbyB: true, + minigameLabel: cardEntry.labelTh, + }; + if (md.gameType === 'gauntlet' && space.gauntletRun) { + gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if (space.gauntletRun && space.gauntletRun.crownRunHeld) { + gameStartPayload.gauntletCrownRunHeld = true; + } + io.to(sid).emit('game-start', gameStartPayload); + return { ok: true, mapId, gameType: md.gameType }; +} + +function returnDetectiveSpaceToLobbyB(sid, space, message) { + ensurePostCaseLobbyMapLoaded(); + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + space.quizSession = null; + space.detectiveMinigameActive = false; + space.gauntletReturnMapId = null; + stopGauntletTicker(space); + space.gauntletRun = null; + // เล่นมินิเกมจบ → เติมหลักฐาน (ติกถูก) 1 ช่องให้ผู้ต้องสงสัยที่กำลังสืบ (สะสมได้สูงสุด 3) + if (!Array.isArray(space.suspectProgress) || space.suspectProgress.length !== 3) space.suspectProgress = [0, 0, 0]; + const _ai = (typeof space.suspectActiveIndex === 'number') ? space.suspectActiveIndex : -1; + if (_ai >= 0 && _ai <= 2) { + space.suspectProgress[_ai] = Math.min(3, (space.suspectProgress[_ai] || 0) + 1); + } + space.suspectActiveIndex = null; + const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); + if (!lb) { + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); + return; + } + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = lb; + space.suspectPhaseActive = true; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(lb, gi++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + const peersSnap = buildPeersSnapForSpace(space); + const payload = { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + peersSnap, + suspectPhaseActive: true, + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + detectiveReturn: true, + }; + io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — กลับ LobbyB', returnToLobbyB: true }); + io.to(sid).emit('detective-minigame-ended', payload); + io.to(sid).emit('game-start', payload); +} + +/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ +function emitTrialVoteUpdate(sid, space) { + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); + io.to(sid).emit('trial-vote-update', { + counts, + voted: Object.keys(votes).length, + totalPlayers: space.peers ? space.peers.size : 0, + }); +} + +function computeAndEmitTrialResult(sid, space) { + space.trialPhase = 'revealed'; + const counts = [0, 0, 0]; + const votes = space.trialVotes || {}; + const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; + const winners = []; + if (!space.trialScores) space.trialScores = {}; + Object.keys(votes).forEach((id) => { + const v = votes[id]; + if (v >= 0 && v <= 2) counts[v]++; + if (v === culprit) { + winners.push(id); + space.trialScores[id] = (space.trialScores[id] || 0) + 100; + } + }); + const winnerNames = winners.map((id) => { + const p = space.peers.get(id); + return (p && p.nickname) ? p.nickname : String(id).slice(0, 6); + }); + io.to(sid).emit('trial-result', { + culpritIndex: culprit, + counts, + winners, + winnerNames, + cardMinigames: space.suspectCardMinigames || [], + }); +} + +function endQuizGame(sid, space, message) { + clearSpaceQuizTimers(space); + if (space.quizSession) space.quizSession.active = false; + if (space.detectiveMinigameActive) { + returnDetectiveSpaceToLobbyB(sid, space, message); + space.quizSession = null; + return; + } + io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' }); + space.quizSession = null; +} + +function emitQuizPlayerStates(sid, space) { + const sess = space.quizSession; + if (!sess || !sess.players) return; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + if (st) io.to(peerId).emit('quiz-player-state', { ...st }); + }); +} + +function scheduleQuizReadPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const idx = sess.qIndex; + const qList = sess.questions; + if (idx >= qList.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + const q = qList[idx]; + const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; + sess.phase = 'read'; + sess.phaseEndsAt = Date.now() + readMs; + io.to(sid).emit('quiz-phase', { + phase: 'read', + questionIndex: idx + 1, + questionTotal: qList.length, + text: String(q.text || '').trim(), + endsAt: sess.phaseEndsAt, + }); + const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); + space.quizTimers.push(t); +} + +function scheduleQuizAnswerPhase(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; + sess.phase = 'answer'; + sess.phaseEndsAt = Date.now() + answerMs; + io.to(sid).emit('quiz-phase', { + phase: 'answer', + questionIndex: sess.qIndex + 1, + questionTotal: sess.questions.length, + endsAt: sess.phaseEndsAt, + }); + emitQuizPlayerStates(sid, space); + const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); + space.quizTimers.push(t); +} + +function resolveQuizRound(sid, space, md) { + const sess = space.quizSession; + if (!sess || !sess.active) return; + /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ + const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; + if (!mdLive) return; + const idx = sess.qIndex; + const q = sess.questions[idx]; + const correctTrue = !!q.answerTrue; + const tGrid = mdLive.quizTrueArea || []; + const fGrid = mdLive.quizFalseArea || []; + + let eligibleCount = 0; + let anyRight = false; + const results = []; + + const ejectMoves = []; + + space.peers.forEach((p, peerId) => { + const st = sess.players[peerId]; + if (!st) return; + eligibleCount++; + const tx = Math.floor(Number(p.x)); + const ty = Math.floor(Number(p.y)); + const inT = quizCellOn(tGrid, tx, ty); + const inF = quizCellOn(fGrid, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + + let right = false; + if (choice === null) right = false; + else right = (choice === correctTrue); + + if (right) { + st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; + anyRight = true; + } + /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ + if (!right) { + st.cannotTrue = true; + st.cannotFalse = true; + const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) + ? Math.max(0, Math.floor(p.spawnJoinOrder)) + : [...space.peers.keys()].indexOf(peerId); + const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); + p.x = pos.x; + p.y = pos.y; + ejectMoves.push({ + id: peerId, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + }); + } + + results.push({ + id: peerId, + nickname: p.nickname || '', + right, + choice, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : 0, + }); + }); + + ejectMoves.forEach((ev) => { + io.to(sid).emit('user-move', ev); + try { + const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); + if (sock) sock.emit('user-move', ev); + } catch (e) { /* ignore */ } + }); + + const allWrong = eligibleCount > 0 && !anyRight; + + const scores = {}; + space.peers.forEach((_, peerId) => { + const st = sess.players[peerId]; + scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; + }); + + io.to(sid).emit('quiz-result', { + questionIndex: idx + 1, + correctTrue, + results, + allWrong, + scores, + }); + emitQuizPlayerStates(sid, space); + + if (allWrong) { + endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); + return; + } + + sess.qIndex++; + if (sess.qIndex >= sess.questions.length) { + endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); + return; + } + + const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; + const mapRef = sess.quizMapMd || md; + const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); + space.quizTimers.push(t); +} + +function startQuizGame(sid, space, md) { + clearSpaceQuizTimers(space); + const settings = loadQuizSettings(); + const pool = getQuizQuestionPool(md); + const shuffled = shuffleQuizQuestions(pool); + const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); + const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); + const players = {}; + space.peers.forEach((_, peerId) => { + players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + }); + space.quizSession = { + active: true, + questions: picked, + qIndex: 0, + phase: 'idle', + phaseEndsAt: 0, + readMs: settings.readMs, + answerMs: settings.answerMs, + betweenMs: settings.betweenMs, + players, + quizMapMd: md, + }; + scheduleQuizReadPhase(sid, space, md); +} + +function safeMapId(id) { + return (id || '').replace(/[^a-z0-9_-]/gi, '') || null; +} + +/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */ +function normalizeQuizCarryLayersOnMap(m) { + if (!m || m.gameType !== 'quiz_carry') return; + const w = m.width || 20, h = m.height || 15; + const normHub = () => { + const src = m.quizCarryHubArea || []; + 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); + } + m.quizCarryHubArea = rows; + }; + const normOptions = () => { + const src = m.quizCarryOptionArea || []; + 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 <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0); + } + rows.push(row); + } + m.quizCarryOptionArea = rows; + }; + const normCountdown = () => { + const src = m.carryEmbedCountdownArea || []; + 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]; + row.push(Number(v) === 1 ? 1 : 0); + } + rows.push(row); + } + m.carryEmbedCountdownArea = rows; + }; + normHub(); + normOptions(); + normCountdown(); +} + +/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */ +function normalizeQuizBattleDomeAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattleDomeArea || []; + 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); + } + m.quizBattleDomeArea = rows; +} + +function normalizeQuizBattlePathAreaOnMap(m) { + if (!m || m.gameType !== 'quiz_battle') return; + const w = m.width || 20, h = m.height || 15; + const src = m.quizBattlePathArea || []; + 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); + } + m.quizBattlePathArea = rows; +} + +function quizBattlePathModeActiveServer(m) { + if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false; + const g = m.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 quizBattleFootprintFullyOnPathServer(m, px, py) { + if (!quizBattlePathModeActiveServer(m)) return true; + const g = m.quizBattlePathArea; + for (const k of quizCarryFootprintTileKeys(m, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (!g[ty] || g[ty][tx] !== 1) return false; + } + return true; +} + +function serverFootprintClearOfWalls(md, px, py) { + const w = md.width || 20, h = md.height || 15; + for (const k of serverWallCollisionTileKeys(md, px, py)) { + const [tx, ty] = k.split(',').map(Number); + if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) return false; + } + return true; +} + +/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */ +function snapPositionOntoQuizBattlePathServer(md, px, py) { + if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py }; + if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py }; + const w = md.width || 20, h = md.height || 15; + const g = md.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 (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue; + if (!serverFootprintClearOfWalls(md, 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 }; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + if (!g[ty] || g[ty][tx] !== 1) continue; + const row = md.objects && md.objects[ty]; + if (!row || row[tx] === 1) 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 }; +} + +function normalizeJumpSurvivePlatformAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurvivePlatformArea = rows; +} + +function normalizeJumpSurvivePlatformVariantAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const pa = m.jumpSurvivePlatformArea || []; + const src = m.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); + } + m.jumpSurvivePlatformVariantArea = rows; +} + +function normalizeJumpSurviveHazardAreaOnMap(m) { + if (!m || m.gameType !== 'jump_survive') return; + const w = m.width || 20, h = m.height || 15; + const src = m.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); + } + m.jumpSurviveHazardArea = rows; +} + +/** + * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON + */ +function applyStackTowerMissionMapPlayPolicy(safeId, m) { + if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return; + const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {}; + const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); + const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up'; + const spNum = Number(raw.speedPxPerSec); + const speedPxPerSec = Number.isFinite(spNum) + ? Math.max(0, Math.min(400, Math.floor(spNum))) + : 0; + const sel = Math.floor(Number(raw.stepEveryLayers)); + const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0; + const ssp = Math.floor(Number(raw.stepScrollPx)); + const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0; + const sam = Math.floor(Number(raw.stepAnimMs)); + const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520; + const st = { + enabled: raw.enabled === true, + speedPxPerSec, + scrollDirection, + stepEveryLayers, + stepScrollPx, + stepAnimMs, + }; + if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage; + if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage; + const rg = Number(raw.releaseGapWorldPx); + if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg)); + m.stackTowerBgScroll = st; + const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {}; + const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12)); + const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260)); + m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx }; +} + +function loadMaps() { + try { + if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true }); + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(MAPS_DIR); + for (const f of files) { + if (!f.endsWith('.json')) continue; + const id = f.slice(0, -5); + if (!safeMapId(id)) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(id, data); + maps.set(id, data); + } catch (e) { console.error('load map', f, e.message); } + } + } catch (e) { console.error('loadMaps', e.message); } +} + +/** + * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที + * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า) + */ +function rehydrateSingleMapFromDisk(safeId) { + if (!safeId) return false; + const fp = path.join(MAPS_DIR, safeId + '.json'); + if (!fs.existsSync(fp)) return false; + try { + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + normalizeJumpSurvivePlatformAreaOnMap(data); + normalizeJumpSurvivePlatformVariantAreaOnMap(data); + normalizeJumpSurviveHazardAreaOnMap(data); + normalizeQuizBattleDomeAreaOnMap(data); + normalizeQuizBattlePathAreaOnMap(data); + applyStackTowerMissionMapPlayPolicy(safeId, data); + maps.set(safeId, data); + return true; + } catch (e) { + console.error('rehydrate map', safeId, e.message); + return false; + } +} + +/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */ +function ensurePostCaseLobbyMapLoaded() { + if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true; + try { + const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json'); + if (!fs.existsSync(fp)) return false; + const data = JSON.parse(fs.readFileSync(fp, 'utf8')); + normalizeQuizCarryLayersOnMap(data); + maps.set(POST_CASE_LOBBY_SPACE_ID, data); + return true; + } catch (e) { + console.error('ensurePostCaseLobbyMapLoaded', e.message); + return false; + } +} + +function saveMap(id, m) { + const safe = safeMapId(id); + if (!safe) return; + try { + if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); + fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8'); + } catch (e) { console.error('saveMap', id, e.message); } +} + +function isMapNameTaken(name, excludeId) { + const n = (name || '').trim().toLowerCase(); + for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true; + return false; +} + +const server = http.createServer((req, res) => { + let url = req.url; + if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html'; + if (url.startsWith(BASE_PATH + '/api/maps')) { + let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0]; + if (id.includes('?')) id = id.split('?')[0]; + if (req.method === 'GET' && id) { + const sid = safeMapId(decodeURIComponent(id)); + if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + rehydrateSingleMapFromDisk(sid); + const m = maps.get(sid); + if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m)); + } + if (req.method === 'GET' && !id) { + const list = [...maps].map(([id, m]) => ({ id, name: m.name })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || 'ฉากใหม่'; + if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const id = Date.now().toString(36); + const m = { ...defaultMap(), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (req.method === 'PUT' && id && maps.has(id)) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body); + const name = (d.name || '').trim() || maps.get(id).name; + if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); + const m = { ...maps.get(id), ...d, name }; + if (!m.blockPlayer) m.blockPlayer = []; + if (!m.interactive) m.interactive = []; + if (!m.startGameArea) m.startGameArea = []; + if (!m.spawnArea) m.spawnArea = []; + if (!m.quizTrueArea) m.quizTrueArea = []; + if (!m.quizFalseArea) m.quizFalseArea = []; + if (!m.quizQuestionArea) m.quizQuestionArea = []; + if (!m.quizQuestions) m.quizQuestions = []; + if (!m.quizCarryHubArea) m.quizCarryHubArea = []; + if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; + if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; + if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; + if (!m.quizBattlePathArea) m.quizBattlePathArea = []; + if (!m.stackReleaseArea) m.stackReleaseArea = []; + if (!m.stackLandArea) m.stackLandArea = []; + if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; + if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; + if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; + if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; + normalizeQuizCarryLayersOnMap(m); + normalizeJumpSurvivePlatformAreaOnMap(m); + normalizeJumpSurvivePlatformVariantAreaOnMap(m); + normalizeJumpSurviveHazardAreaOnMap(m); + normalizeQuizBattleDomeAreaOnMap(m); + normalizeQuizBattlePathAreaOnMap(m); + applyStackTowerMissionMapPlayPolicy(id, m); + maps.set(id, m); + saveMap(id, m); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, mapId: id })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + const quizCarryDiskPaths = new Set([ + BASE_PATH + '/api/quiz-carry-from-disk', + BASE_PATH + '/api-quiz-carry-from-disk.php', + ]); + if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) { + if (req.method === 'GET') { + const g = loadQuizSettings(); + const slice = { + carryMapPanelTheme: g.carryMapPanelTheme, + carryEmbedCountdownTheme: g.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: g.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: g.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale, + carryReadMs: g.carryReadMs, + carryAnswerMs: g.carryAnswerMs, + carrySessionLength: g.carrySessionLength, + carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: g.carryWalkSpeedMult, + carryQuestions: g.carryQuestions, + }; + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(slice)); + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(loadQuizSettings())); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveQuizSettings(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + battleQuizMcqSaved: saved.battleQuizMcqSaved, + carryQuestionsSaved: saved.carryQuestionsSaved, + carryMapPanelTheme: saved.carryMapPanelTheme, + carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + }); + return res.end(JSON.stringify({ ...runtimeGameTiming })); + } + if (req.method === 'PUT') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + try { + const d = JSON.parse(body || '{}'); + const saved = saveGameTimingToFile(d); + if (!saved.ok) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); + } + res.writeHead(200); + const { ok: _ok, error: _err, ...timingRest } = saved; + return res.end(JSON.stringify({ ok: true, ...timingRest })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } + const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() })); + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureGauntletAssetsDir(); + const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : ''; + let fname; + if (replaceFilename) { + const s = safeGauntletStoredFilename(replaceFilename); + if (!s) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' })); + } + const cur = path.join(GAUNTLET_ASSETS_DIR, s); + if (!fs.existsSync(cur)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' })); + } + const norm = (e) => { + let x = String(e || '').toLowerCase(); + if (x.startsWith('.')) x = x.slice(1); + return x === 'jpeg' ? 'jpg' : x; + }; + if (norm(path.extname(s)) !== norm(ext)) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' })); + } + fname = s; + } else { + fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`; + } + fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf); + const meta = loadGauntletAssetsMeta(); + const prev = meta[fname] || {}; + meta[fname] = { + ...prev, + label: labelIn || prev.label || fname, + updatedAt: Date.now(), + }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/gauntlet-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const dataUrl = d.imageDataUrl; + if (!dataUrl || typeof dataUrl !== 'string') { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); + } + const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); + if (!m) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); + } + const extRaw = m[1].toLowerCase(); + const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; + const b64 = m[2].replace(/\s/g, ''); + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); + } + if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); + } + ensureQuizCarryPlaqueAssetsDir(); + const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`; + fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf); + res.writeHead(200); + return res.end(JSON.stringify({ + ok: true, + filename: fname, + url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname, + })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const fname = safeGauntletStoredFilename(d.filename); + if (!fname) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' })); + } + const meta = loadGauntletAssetsMeta(); + const label = d.label != null ? String(d.label).trim().slice(0, 120) : ''; + meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() }; + saveGauntletAssetsMeta(meta); + res.writeHead(200); + return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label })); + } catch (e) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' })); + } + }); + return; + } + if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') { + try { + const q = url.includes('?') ? url.split('?')[1] : ''; + const sp = new URLSearchParams(q); + const rawFn = sp.get('file'); + const fname = safeGauntletStoredFilename(rawFn); + if (!fname) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' })); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (fs.existsSync(fp)) fs.unlinkSync(fp); + const meta = loadGauntletAssetsMeta(); + delete meta[fname]; + saveGauntletAssetsMeta(meta); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' })); + } + } + if (url === BASE_PATH + '/api/characters' && req.method === 'GET') { + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const ids = new Set(); + const ext = /\.(png|jpg|jpeg|gif|webp)$/i; + files.forEach(f => { + let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); + if (m) ids.add(m[1]); + }); + const idArr = [...ids]; + const oldestMsById = {}; + idArr.forEach((id) => { + let oldest = Infinity; + const prefix = id + '_'; + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + try { + const st = fs.statSync(path.join(CHARACTERS_DIR, f)); + const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime(); + if (t < oldest) oldest = t; + } catch (e) { /* ignore */ } + }); + oldestMsById[id] = oldest === Infinity ? 0 : oldest; + }); + idArr.sort((a, b) => { + const ta = oldestMsById[a] || 0; + const tb = oldestMsById[b] || 0; + if (ta !== tb) return ta - tb; + return String(a).localeCompare(String(b)); + }); + const list = idArr.map((id) => { + const prefix = id + '_'; + const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f)); + const layerNameSet = new Set(); + if (hasLayerFiles) { + files.forEach((f) => { + if (!f.startsWith(prefix) || !ext.test(f)) return; + let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) { + layerNameSet.add(m[1]); + return; + } + m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); + if (m) layerNameSet.add(m[1]); + }); + } + const layers = layerNameSet.size ? [...layerNameSet].sort() : []; + const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null; + return { id, name: id, hasLayerFiles, layers, layerManifest }; + }); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(list)); + } catch (e) { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify([])); + } + } + const urlPath = url.split('?')[0]; + { + const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$')); + if (lm && req.method === 'GET') { + const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const manifest = buildCharacterLayerManifest(id, files); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify(manifest)); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} })); + } + } + } + if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') { + if (/\/layer-manifest$/i.test(urlPath)) { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); + return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' })); + } + const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length)); + const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, ''); + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' })); + } + try { + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const files = fs.readdirSync(CHARACTERS_DIR); + const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); + let removed = 0; + files.forEach(f => { + if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) { + try { + fs.unlinkSync(path.join(CHARACTERS_DIR, f)); + removed++; + } catch (e) { + // ignore single file errors + } + } + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, removed })); + } catch (e) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') })); + } + return; + } + if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + if (!body || body.length < 2) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' })); + } + const d = JSON.parse(body); + const dirs = ['up', 'down', 'left', 'right']; + const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now(); + if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); + const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 }; + let walkWritten = 0; + for (const dir of dirs) { + let data = d[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + // เขียนไฟล์เฟรม (สำหรับ animation) + const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + walkWritten++; + frameIndex++; + } + } + let layerWritten = 0; + // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html) + const lf = d.layerFrames; + if (lf && typeof lf === 'object') { + for (const dir of dirs) { + const arr = lf[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const lfIdle = d.layerFramesIdle; + if (lfIdle && typeof lfIdle === 'object') { + for (const dir of dirs) { + const arr = lfIdle[dir]; + if (!Array.isArray(arr)) continue; + const multi = arr.length > 1; + arr.forEach((frameObj, frameIndex) => { + if (!frameObj || typeof frameObj !== 'object') return; + for (const layerName of Object.keys(frameObj)) { + if (!ALLOWED_LAYER[layerName]) continue; + const dataUrl = frameObj[layerName]; + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameLayer = multi + ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png') + : (id + '_' + dir + '_idle_layer_' + layerName + '.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); + layerWritten++; + } + }); + } + } + const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null; + if (idleObj) { + for (const dir of dirs) { + let data = idleObj[dir]; + if (!data) continue; + const list = Array.isArray(data) ? data : [data]; + let frameIndex = 0; + for (const dataUrl of list) { + if (!dataUrl || typeof dataUrl !== 'string') continue; + const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); + if (!m) continue; + const b64 = m[1].replace(/\s/g, ''); + if (!b64.length) continue; + let buf; + try { + buf = Buffer.from(b64, 'base64'); + } catch (err) { + continue; + } + if (buf.length === 0) continue; + const fnameFrame = list.length > 1 + ? (id + '_' + dir + '_idle_' + frameIndex + '.png') + : (id + '_' + dir + '_idle.png'); + fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); + if (list.length > 1 && frameIndex === 0) { + const baseName = id + '_' + dir + '_idle.png'; + fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); + } + frameIndex++; + } + } + } + if (walkWritten === 0 && layerWritten === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' })); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, characterId: id })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/api/spaces')) { + const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean); + const spaceId = parts[0]; + if (req.method === 'GET' && !spaceId) { + const now = Date.now(); + for (const [id, s] of [...spaces.entries()]) { + if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue; + const n = s.peers ? s.peers.size : 0; + if (n !== 0) continue; + const created = s.createdAt; + const stale = created == null || now - created > SPACE_EMPTY_TTL_MS; + if (stale) spaces.delete(id); + } + const list = [...spaces] + .filter(([, s]) => !s.isPrivate) + .filter(([, s]) => (s.peers && s.peers.size > 0)) + .map(([id, s]) => ({ + spaceId: id, + spaceName: s.spaceName || id, + mapName: (s.mapData && s.mapData.name) || '—', + peerCount: s.peers ? s.peers.size : 0, + maxPlayers: s.maxPlayers || 10 + })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); + } + if (req.method === 'GET' && spaceId) { + const s = spaces.get(spaceId); + if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' })); + return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name })); + } + if (req.method === 'POST' && !spaceId) { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + try { + const d = JSON.parse(body || '{}'); + const mapData = d.mapId ? maps.get(d.mapId) : null; + if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' })); + const isPrivate = !!d.isPrivate; + let sid; + if (isPrivate) { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code; + for (let i = 0; i < 20; i++) { + code = ''; + for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)]; + if (!spaces.has(code)) { sid = code; break; } + } + if (!sid) sid = 'priv-' + Date.now(); + } else { + const base = (d.name || '').trim() || 'space'; + sid = base + '-' + Date.now(); + } + const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid); + let maxPlayers = parseInt(d.maxPlayers, 10); + if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10; + maxPlayers = Math.min(10, Math.max(1, maxPlayers)); + if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6); + const mapId = d.mapId ? String(d.mapId).trim() : null; + const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview'; + let botSlotCount = parseInt(d.botSlotCount, 10); + if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0; + botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers }); + spaces.set(sid, { + mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(), + previewEditorTest, + botSlotCount, + suspectCardMinigames: null, + suspectPhaseActive: false, + suspectPickIndex: 0, + detectiveMinigameActive: false, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + } + if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') { + function sendIceServers(iceServers) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ iceServers: iceServers || [] })); + } + function fallback() { + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + } + const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY; + const meteredAppId = process.env.METERED_TURN_APP_ID; + let meteredBase = process.env.METERED_TURN_URL; + if (!meteredBase) { + let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret'; + if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live'; + meteredBase = 'https://' + meteredHost; + } + meteredBase = meteredBase.replace(/\/$/, ''); + if (meteredKey) { + function tryNext(credUrl) { + return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) { + if (data && (data.error || data.message)) throw new Error(data.error || data.message); + const list = Array.isArray(data) ? data : (data && data.iceServers); + if (list && list.length) return sendIceServers(list); + if (data && data.data && Array.isArray(data.data) && data.data.length) { + const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca'; + const built = data.data.map(function (c) { + return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password }; + }); + return sendIceServers(built); + } + throw new Error('No iceServers'); + }); + } + const urlsToTry = [ + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey), + meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey), + meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey) + ]; + let p = tryNext(urlsToTry[0]); + for (let i = 1; i < urlsToTry.length; i++) { + p = p.catch(function () { return tryNext(urlsToTry[i]); }); + } + p.catch(fallback); + return; + } + const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; + const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; + const turnUser = process.env.TURN_USER; + const turnCred = process.env.TURN_CRED; + if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); + sendIceServers(iceServers); + return; + } + if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const password = (d.password || '').trim(); + if (password !== ADMIN_PASSWORD) { + return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' })); + } + const token = require('crypto').randomBytes(24).toString('hex'); + adminTokens.add(token); + res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax'); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + const s = loadAiSettings(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + model: s.model || 'gpt-4o-mini', + models: AI_MODELS, + hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0), + intent: s.intent != null ? s.intent : '', + rag_enabled: !!s.rag_enabled, + rag_context: s.rag_context != null ? s.rag_context : '', + })); + return; + } + if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') { + const token = getAdminToken(req.headers.cookie); + if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json'); + try { + const d = JSON.parse(body || '{}'); + const s = loadAiSettings(); + if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim(); + if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini'; + if (d.intent !== undefined) s.intent = String(d.intent); + if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled; + if (d.rag_context !== undefined) s.rag_context = String(d.rag_context); + saveAiSettings(s); + res.writeHead(200); + res.end(JSON.stringify({ ok: true })); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message })); + } + }); + return; + } + if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') { + let body = ''; + req.on('data', c => body += c); + req.on('end', () => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + const d = JSON.parse(body || '{}'); + const message = (d.message || d.text || '').trim(); + if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' })); + const s = loadAiSettings(); + if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' })); + const model = s.model || 'gpt-4o-mini'; + const messages = []; + const systemParts = []; + if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim()); + if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) { + systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim()); + } + if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') }); + messages.push({ role: 'user', content: message }); + const payload = JSON.stringify({ + model: model, + messages: messages, + max_tokens: 500, + }); + const urlObj = new URL('https://api.openai.com/v1/chat/completions'); + const opts = { + hostname: urlObj.hostname, + path: urlObj.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + s.openai_api_key, + 'Content-Length': Buffer.byteLength(payload, 'utf8'), + }, + }; + const reqOut = require('https').request(opts, (resOut) => { + let data = ''; + resOut.on('data', chunk => data += chunk); + resOut.on('end', () => { + try { + const parsed = JSON.parse(data); + const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content; + if (text != null) { + res.writeHead(200); + res.end(JSON.stringify({ response: String(text).trim() })); + } else { + res.writeHead(502); + res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' })); + } + } catch (e) { + res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' })); + } + }); + }); + reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); }); + reqOut.write(payload); + reqOut.end(); + } catch (e) { + res.writeHead(400), res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') { + const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0]; + const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, ''); + if (fname && fname.length > 0) { + const fp = path.join(CHARACTERS_DIR, fname); + if (fs.existsSync(fp)) { + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(data); + }); + } + } + } + if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') { + const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]); + const fname = safeGauntletStoredFilename(sub); + if (!fname) { + res.writeHead(400); + return res.end(); + } + const fp = path.join(GAUNTLET_ASSETS_DIR, fname); + if (!fs.existsSync(fp)) { + res.writeHead(404); + return res.end(); + } + const ext = path.extname(fname); + return fs.readFile(fp, (err, data) => { + if (err) return res.writeHead(500), res.end(); + res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) }); + res.end(data); + }); + } + if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html'; + const filePath = path.join(__dirname, 'public', url); + const ext = path.extname(filePath); + const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' }; + fs.readFile(filePath, (err, data) => { + if (err) return res.writeHead(404), res.end('Not Found'); + res.writeHead(200, { + 'Content-Type': types[ext] || 'application/octet-stream', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + res.end(data); + }); +}); + +const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } }); +const spaces = new Map(); + +const GAUNTLET_MAX_PLAYERS = 6; +/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */ +/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */ +const GAUNTLET_LASER_SPAWN_CHANCE = 0.14; +/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */ +const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34; +/** Last Light / museum gauntlet — scoring, mission UI, how-to flow */ +const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07'; +/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */ +const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; +/** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */ +const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; + +function isGauntletCrownHeistMapBySpace(space) { + return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); +} + +function isBalloonBossMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'balloon_boss'); +} + +/** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */ +function isQuizQuestionMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'quiz'); +} + +function isStackTowerMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'stack'); +} + +function isJumpSurviveMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'jump_survive'); +} + +function isSpaceShooterMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'space_shooter'); +} + +function isCrownLobbyShellSpace(space) { + return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space); +} + +function newBalloonBossShellGauntletRunState() { + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 999, + crownRunHeld: true, + timerId: null, + endsAt: null, + }; +} + +function emitBalloonBossMissionShellGauntletSync(sid, space) { + if (!isBalloonBossMissionShellSpace(space)) return; + const gr = space.gauntletRun; + io.to(sid).emit('gauntlet-sync', { + gauntletCrownRunHeld: !!(gr && gr.crownRunHeld), + }); +} + +function rollGauntletCrownRewardCard(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' }; +} + +function gauntletCrownRankBonus(rankOrdinal) { + if (rankOrdinal === 1) return 100; + if (rankOrdinal === 2) return 80; + if (rankOrdinal === 3) return 60; + return 0; +} + +function buildGauntletCrownMissionPayload(space) { + const peersArr = [...space.peers.values()]; + const rows = peersArr.map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + characterId: p.characterId ?? null, + baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0), + eliminated: !!p.gauntletEliminated, + })); + rows.sort((a, b) => { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th'); + }); + let lastScore = null; + let rankOrdinal = 0; + const ranked = []; + for (let i = 0; i < rows.length; i++) { + const pos = i + 1; + if (lastScore !== rows[i].baseScore) { + rankOrdinal = pos; + lastScore = rows[i].baseScore; + } + const bonus = gauntletCrownRankBonus(rankOrdinal); + const finalScore = rows[i].baseScore + bonus; + const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal); + ranked.push({ + id: rows[i].id, + nickname: rows[i].nickname, + characterId: rows[i].characterId, + baseScore: rows[i].baseScore, + eliminated: rows[i].eliminated, + rank: rankOrdinal, + rankLabel, + rankBonus: bonus, + finalScore, + }); + } + const totalSum = ranked.reduce((s, r) => 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 = rollGauntletCrownRewardCard(grade); + return { + ranked, + totalSum, + averageScore, + grade, + rewardCard, + totalParts: ranked.map((r) => r.finalScore), + }; +} + +function gauntletSpawnYs(md, playerCount) { + const h = md.height || 15; + const lo = 1; + const hi = Math.max(lo, h - 2); + const n = Math.min(GAUNTLET_MAX_PLAYERS, 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; +} + +/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */ +function collectGauntletSpawnSlotsFromSpawnArea(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 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y }); + } + } + slots.sort((a, b) => a.y - b.y || a.x - b.x); + return slots; +} + +/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */ +function collectGauntletSpawnSlotsFromMap(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_MAX_PLAYERS) 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 (!isMapTileWalkableForSpawn(md, x, y)) continue; + slots.push({ x, y }); + } + if (slots.length > 0) return slots; + } + return collectGauntletSpawnSlotsFromSpawnArea(md); +} + +function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) { + const w = md.width || 20; + if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX; + if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback; + for (let d = 0; d < w; d++) { + if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d; + if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d; + } + return Math.max(0, Math.min(w - 1, spawnColX)); +} + +/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */ +function gauntletSpawnPositions(md, playerCount) { + const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount)); + const slots = collectGauntletSpawnSlotsFromMap(md); + const fallbackYs = gauntletSpawnYs(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 = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX); + out.push({ x, y }); + } + return out; +} + +function stopGauntletTicker(space) { + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } +} + +/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */ +function ensureGauntletEndsAtIfNeeded(space) { + const gr = space.gauntletRun; + if (!gr) return; + if (gr.crownRunHeld) return; + const lim = getGauntletTimeLimitSec(); + if (lim > 0 && gr.endsAt == null) { + gr.endsAt = Date.now() + lim * 1000; + } +} + +function newGauntletRunState(space) { + const crown = !!(space && isGauntletCrownHeistMapBySpace(space)); + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 3, + crownRunHeld: crown, + }; +} + +function emitGauntletSync(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + const gr = space.gauntletRun; + if (!gr) return; + const h = md.height || 15; + const obsOut = gr.obstacles.map((o) => { + if (o.kind === 'laser') { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1; + return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 }; + } + return { id: o.id, kind: o.kind, x: o.x, y: o.y }; + }); + const playersOut = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + io.to(sid).emit('gauntlet-sync', { + obstacles: obsOut, + players: playersOut, + gauntletTickMs: getGauntletTickMs(), + gauntletJumpTicks: getGauntletJumpTicks(), + gauntletTimeLimitSec: getGauntletTimeLimitSec(), + gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null, + gauntletCrownRunHeld: !!gr.crownRunHeld, + ...getGauntletVisualsForClient(), + }); +} + +function startGauntletTicker(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.timerId) return; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); +} + +function initGauntletPeersAll(space, md) { + const list = [...space.peers.values()]; + const pos = gauntletSpawnPositions(md, list.length); + const crown = isGauntletCrownHeistMapBySpace(space); + list.forEach((p, i) => { + const pt = pos[i] || pos[pos.length - 1]; + p.x = pt.x; + p.y = pt.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = crown ? 100 : 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + space.gauntletRun = newGauntletRunState(space); + if (crown) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + } + space.gauntletPreviewRowsBySocket = new Map(); +} + +function findFallbackLobbyMapForGauntletEnd() { + for (const [id, m] of maps) { + if (m && m.gameType === 'lobby') return { id, m }; + } + return null; +} + +/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */ +function endGauntletGame(sid, space, reason) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.detectiveMinigameActive) { + const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; + returnDetectiveSpaceToLobbyB(sid, space, msg); + return; + } + stopGauntletTicker(space); + const wasCrown = isGauntletCrownHeistMapBySpace(space); + const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; + const rankings = crownMission + ? crownMission.ranked.map((r) => ({ + id: r.id, + nickname: r.nickname, + score: r.finalScore, + })) + : [...space.peers.values()].map((p) => ({ + id: p.id, + nickname: (p.nickname || '').trim() || 'ผู้เล่น', + score: Math.max(0, p.gauntletScore | 0), + })).sort((a, b) => b.score - a.score); + + let retId = space.gauntletReturnMapId; + let retMap = retId && maps.has(retId) ? maps.get(retId) : null; + if (!retMap || retMap.gameType === 'gauntlet') { + const fb = findFallbackLobbyMapForGauntletEnd(); + if (fb) { + retId = fb.id; + retMap = fb.m; + } + } + if (retMap && retMap.gameType !== 'gauntlet') { + space.mapId = retId; + space.mapData = retMap; + let gi = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(retMap, gi++); + p.x = sp.x; + p.y = sp.y; + p.gauntletJumpTicks = 0; + p.gauntletScore = 0; + p.gauntletJumpPending = false; + p.gauntletEliminated = false; + }); + } else { + console.warn('endGauntletGame: no lobby map to return to', sid); + } + space.gauntletReturnMapId = null; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); + space.gauntletRun = null; + const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; + io.to(sid).emit('gauntlet-ended', { + reason: reason || 'time', + message: msg, + rankings, + crownMission: crownMission || undefined, + }); +} + +/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ +function gauntletLaserVerticalSpanFromMap(md) { + const hRaw = md && md.height != null ? Number(md.height) : 15; + const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); + const rs = md && md.gauntletLaserRowStart; + const re = md && md.gauntletLaserRowEnd; + if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { + return { y0: 0, y1: h - 1 }; + } + let y0 = Math.floor(Number(rs)); + let y1 = Math.floor(Number(re)); + 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 }; +} + +/** + * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) + * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) + */ +function gauntletLaneGridRowFromPlayerTop(md, topY, h) { + const { ch } = getCharacterFootprintWHForMove(md); + const top = Math.floor(Number(topY)); + if (!Number.isFinite(top) || top < 0 || top >= h) return null; + const lr = top + ch - 1; + if (lr < 0 || lr >= h) return null; + return lr; +} + +function runGauntletTick(sid, space) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + return; + } + const w = md.width || 20; + const h = md.height || 15; + const gr = space.gauntletRun; + if (!gr) return; + ensureGauntletEndsAtIfNeeded(space); + if (gr.endsAt != null && Date.now() >= gr.endsAt) { + endGauntletGame(sid, space, 'time'); + return; + } + + if (gr.crownRunHeld) { + space.peers.forEach((p) => { + p.gauntletJumpPending = false; + }); + emitGauntletSync(sid, space); + return; + } + + /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ + space.peers.forEach((p) => { + if (p.gauntletJumpPending) { + if ((p.gauntletJumpTicks || 0) === 0) { + p.gauntletJumpTicks = getGauntletJumpTicks(); + } + p.gauntletJumpPending = false; + } + }); + + for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; + gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); + + const laneSpawnRows = new Set(); + space.peers.forEach((peer) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + /* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */ + const previewRows = space.gauntletPreviewRowsBySocket; + if (previewRows && previewRows.size) { + previewRows.forEach((set) => { + set.forEach((y) => { + const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); + if (lr != null) laneSpawnRows.add(lr); + }); + }); + } + /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ + gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); + + gr.spawnAcc = (gr.spawnAcc || 0) + 1; + if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { + gr.spawnAcc = 0; + gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); + /* ประเภท 2: เลเซอร์ — ทุกแถว */ + if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { + const span = gauntletLaserVerticalSpanFromMap(md); + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); + } + /* ประเภท 1: แยกเลน — แถวเท้า (ไม่ใช่แถวหัว; ไม่ใช่แถวใต้เท้าอีกช่อง) */ + laneSpawnRows.forEach((ly) => { + if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { + gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); + } + }); + } + + /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ + const crownHeist = isGauntletCrownHeistMapBySpace(space); + const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); + space.peers.forEach((p) => { + if (crownHeist && p.gauntletEliminated) { + return; + } + let px = Math.floor(Number(p.x)) || 0; + let py = Math.floor(Number(p.y)) || 0; + px = Math.max(0, Math.min(w - 1, px)); + py = Math.max(0, Math.min(h - 1, py)); + const air = (p.gauntletJumpTicks || 0) > 0; + let advanceX = false; + let hitBack = false; + for (const o of gr.obstacles) { + if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { + if (air) advanceX = true; + else hitBack = true; + } + if (o.kind === 'laser' && o.x === px) { + const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; + const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; + const lo = Math.min(y0, y1); + const hi = Math.max(y0, y1); + if (py < lo || py > hi) { + /* เลเซอร์ไม่ครอบแถวนี้ */ + } else if (air) { + advanceX = true; + } else { + hitBack = true; + } + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + p.gauntletJumpTicks = 0; + if (!crownHeist) { + p.gauntletScore = (p.gauntletScore || 0) + 1; + } + } else if (hitBack) { + if (crownHeist) { + if (px <= 0) { + p.gauntletEliminated = true; + p.gauntletScore = 0; + } else { + p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); + px = Math.max(0, px - 1); + } + } else { + px = Math.max(0, px - 1); + } + } + if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; + p.x = px; + p.y = py; + }); + + emitGauntletSync(sid, space); +} + +function rescheduleAllGauntletTickers() { + for (const [sid, space] of spaces) { + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') continue; + if (space.gauntletRun && space.gauntletRun.timerId) { + clearInterval(space.gauntletRun.timerId); + space.gauntletRun.timerId = null; + } + if (space.peers.size === 0) continue; + if (!space.gauntletRun) { + space.gauntletRun = newGauntletRunState(space); + } + ensureGauntletEndsAtIfNeeded(space); + space.gauntletRun.timerId = setInterval(() => { + runGauntletTick(sid, space); + }, getGauntletTickMs()); + } +} + +function getLobbyLayoutMapForSpace(space) { + return (space.mapId && maps.get(space.mapId)) || space.mapData; +} + +/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ +function serverMapIsPostCaseLobbyB(space) { + if (!space) return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + const md = getLobbyLayoutMapForSpace(space); + if (!md || md.gameType !== 'lobby') return false; + const nm = String(md.name || '').trim().toLowerCase(); + return nm === 'lobbyb'; +} + +/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ +function normalizeLobbyNickname(nickname) { + const raw = String(nickname || '').trim(); + if (!raw) return 'ผู้เล่น'; + try { + return raw.normalize('NFKC').toLowerCase(); + } catch (e) { + return raw.toLowerCase(); + } +} + +/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ +function lobbySpaceAlreadyLobbyB(space, md) { + if (!md || md.gameType !== 'lobby') return false; + if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; + if (String(md.name || '').trim() === 'LobbyB') return true; + return false; +} + +function lobbyMapHasStartGameArea(md) { + if (!md) return false; + const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; + if (md.gameType !== 'lobby' && !namedLobbyA) return false; + const area = md.startGameArea; + if (!area || !Array.isArray(area)) return false; + for (let y = 0; y < area.length; y++) { + const row = area[y]; + if (!row) continue; + for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; + } + return false; +} + +/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ +function lobbyHostStandingInStartArea(space, hostId) { + const md = getLobbyLayoutMapForSpace(space); + if (!lobbyMapHasStartGameArea(md)) return true; + const p = space.peers.get(hostId); + if (!p || p.x == null || p.y == null) return false; + const tx = Math.floor(p.x); + const ty = Math.floor(p.y); + const row = md.startGameArea[ty]; + return !!(row && row[tx] === 1); +} + +function isMapTileWalkableForSpawn(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 mapCharacterFootprintWH(md) { + const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); + const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); + return { cw, ch }; +} + +/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ +function isMapSpawnAnchorWalkable(md, x, y) { + if (!isMapTileWalkableForSpawn(md, x, y)) return false; + const { cw, ch } = mapCharacterFootprintWH(md); + const w = md.width || 20; + const h = md.height || 15; + const maxX = Math.min(w, x + cw); + const maxY = Math.min(h, y + ch); + for (let ty = y; ty < maxY; ty++) { + for (let tx = x; tx < maxX; tx++) { + if (!isMapTileWalkableForSpawn(md, tx, ty)) return false; + } + } + return true; +} + +/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ +function parseLobbyPlayerSpawnsFromMap(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: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ +function augmentLobbySlotsFromShooterPaintJumpSurvive(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; + } + } + } + } +} + +/** + * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) + * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม + */ +function pickSpawnForJoin(md, joinOrderIndex) { + if (!md) return { x: 1, y: 1 }; + const mode = md.lobbySpawnMode; + const ord = joinOrderIndex | 0; + if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(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 (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; + return pickRandomSpawnFromMap(md); + } + if (mode === 'slots6') { + const slots = parseLobbyPlayerSpawnsFromMap(md); + augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); + const pick = slots[j]; + if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; + return pickRandomSpawnFromMap(md); + } + return pickRandomSpawnFromMap(md); +} + +/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ +function pickRandomSpawnFromMap(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 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const idx = typeof crypto.randomInt === 'function' + ? crypto.randomInt(0, pool.length) + : Math.floor(Math.random() * pool.length); + const pick = pool[idx]; + return { x: pick.x, y: pick.y }; +} + +/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ +function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { + const ord = joinOrderIndex | 0; + const sp = pickSpawnForJoin(md, ord); + return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); +} + +function initTroublesomeState(space) { + if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); + if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; + if (space.suspectPickIndex == null) space.suspectPickIndex = 0; + if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; +} + +function attemptTroublesomeRoll(sid, force) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + const peerIds = [...space.peers.keys()]; + if (peerIds.length === 0) return; + if (!force && space.troublesomeEligible.size < peerIds.length) return; + space.troublesomeOfferSent = true; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); + if (pool.length === 0) pool = peerIds; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + space.troublesomeTargetId = chosen; + space.troublesomeResponseReceived = false; + io.to(chosen).emit('troublesome-offer', { seconds: 15 }); +} + +function scheduleTroublesomeRoll(sid) { + const space = spaces.get(sid); + if (!space || space.troublesomeOfferSent) return; + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450); + if (!space.troublesomeMaxTimer) { + space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000); + } +} + +/** + * ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์ + * — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview + */ +function spaceAllowsQuizCarryLobbyRelaxed(space) { + if (!space) return false; + if (space.mapId) { + const byId = maps.get(space.mapId); + if (byId && byId.gameType === 'quiz_carry') return true; + } + const md = space.mapData; + if (md && md.gameType === 'quiz_carry') return true; + return !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); +} + +io.on('connection', (socket) => { + socket.on('join-space', ({ spaceId, nickname, characterId, playMapId }, cb) => { + const space = spaces.get(spaceId); + if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' }); + const maxPlayers = space.maxPlayers || 10; + const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { + return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); + } + if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' }); + const joinNickNorm = normalizeLobbyNickname(nickname); + if (space.joinLocked) { + const wl = space.caseParticipantNicknames; + if (wl && wl.size > 0) { + if (!wl.has(joinNickNorm)) { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } else { + return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); + } + } + socket.join(spaceId); + socket.data.spaceId = spaceId; + if (!space.hostId) space.hostId = socket.id; + if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); + const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; + const spawnJoinOrder = space.peers.size; + const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); + const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3)); + const peer = { + id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, + spawnJoinOrder, + gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, + balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, + }; + if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { + const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5); + peer.x = sn.x; + peer.y = sn.y; + } + space.peers.set(socket.id, peer); + /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ + if (space.detectiveMinigameActive) { + const clientMap = playMapId != null ? String(playMapId).trim() : ''; + const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; + if (!clientMap || !activeMap || clientMap !== activeMap) { + returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); + } + } + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + space.quizCarryLobbyReady[socket.id] = false; + io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + if (mdJoin.gameType === 'gauntlet') { + const ord = [...space.peers.keys()].indexOf(socket.id); + const pos = gauntletSpawnPositions(mdJoin, space.peers.size); + const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1]; + peer.x = pt.x; + peer.y = pt.y; + peer.gauntletJumpTicks = 0; + if (isGauntletCrownHeistMapBySpace(space)) { + peer.gauntletScore = 100; + peer.gauntletEliminated = false; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + const peersList = [...space.peers.values()]; + const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; + const joinCb = { + ok: true, + mapData: mapDataOut, + mapId: space.mapId || null, + peers: peersList, + hostId: space.hostId, + spaceName: space.spaceName || spaceId, + isPrivate: !!space.isPrivate, + roomCode: space.isPrivate ? spaceId : null, + maxPlayers: space.maxPlayers || 10, + suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), + suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + botSlotCount: effectiveBotSlotCount(space), + joinLocked: !!space.joinLocked, + }; + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, + carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, + carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, + carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, + carryWalkSpeedMult: qs.carryWalkSpeedMult, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'quiz') { + try { + const qsQuiz = loadQuizSettings(); + joinCb.quizSettingsSnap = { + quizMapPanelTheme: qsQuiz.quizMapPanelTheme, + }; + } catch (e) { /* ignore */ } + } + if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { + joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; + } + if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } + if (typeof cb === 'function') cb(joinCb); + socket.to(spaceId).emit('user-joined', peer); + if (space.voiceInits) { + for (const [peerId, initData] of Object.entries(space.voiceInits)) { + if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); + } + } + }); + + /** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */ + socket.on('preview-claim-host', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + const isPreviewEditorRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' }); + space.hostId = socket.id; + io.to(sid).emit('host-changed', { hostId: socket.id }); + return reply({ ok: true }); + }); + + socket.on('troublesome-eligible', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return; + initTroublesomeState(space); + space.troublesomeEligible.add(socket.id); + scheduleTroublesomeRoll(sid); + }); + + socket.on('troublesome-response', (data) => { + const accept = !!(data && data.accept); + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return; + if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return; + if (space.troublesomeResponseReceived) return; + space.troublesomeResponseReceived = true; + space.troublesomeAccept = accept; + let idx = Math.floor(Number(space.suspectPickIndex)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; + space.suspectPickIndex = idx; + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + space.suspectPhaseActive = true; + io.to(sid).emit('suspect-phase-open', { + hostId: space.hostId, + selectedIndex: space.suspectPickIndex, + disruptorAccepted: accept, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + socket.emit('troublesome-ack', { ok: true, accept }); + }); + + socket.on('suspect-pick-select', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return; + if (space.hostId !== socket.id) return; + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return; + space.suspectPickIndex = idx; + io.to(sid).emit('suspect-pick-update', { selectedIndex: idx }); + }); + + socket.on('suspect-pick-start', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (space && Array.isArray(space.suspectCardMinigames)) { + space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry); + } + if (!space || !serverMapIsPostCaseLobbyB(space)) { + return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' }); + } + if (!space.suspectPhaseActive) { + return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' }); + } + if (space.hostId !== socket.id) { + return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' }); + } + if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { + assignDetectiveSuspectCardMinigames(space); + } + const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; + const cardEntry = space.suspectCardMinigames[selectedIndex]; + if (!cardEntry || !cardEntry.mapId) { + return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); + } + const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); + reply(started); + }); + + socket.on('detective-minigame-finished', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + } + if (!space.detectiveMinigameActive) { + if (serverMapIsPostCaseLobbyB(space)) return reply({ ok: true, alreadyOnLobbyB: true }); + return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); + } + returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB'); + reply({ ok: true }); + }); + + // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) + socket.on('suspect-accuse-open', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); + const prog = Array.isArray(space.suspectProgress) ? space.suspectProgress : [0, 0, 0]; + if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน' }); + if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); + space.trialPhase = 'voting'; + space.trialVotes = {}; + space.suspectPhaseActive = false; + io.to(sid).emit('trial-open', { + hostId: space.hostId, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + emitTrialVoteUpdate(sid, space); + reply({ ok: true }); + }); + + // ผู้เล่นโหวตชี้ตัวคนร้าย + socket.on('trial-vote', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); + let idx = Math.floor(Number(data && data.index)); + if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); + space.trialVotes = space.trialVotes || {}; + space.trialVotes[socket.id] = idx; + emitTrialVoteUpdate(sid, space); + if (Object.keys(space.trialVotes).length >= space.peers.size && space.peers.size > 0) { + computeAndEmitTrialResult(sid, space); + } + reply({ ok: true, index: idx }); + }); + + // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) + socket.on('trial-reveal', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); + if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); + computeAndEmitTrialResult(sid, space); + reply({ ok: true }); + }); + + /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ + socket.on('set-ready', ({ ready }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.ready = !!ready; + io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); + break; + } + } + }); + + socket.on('start-game', (data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id) && space.hostId === socket.id) { + let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; + let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; + if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; + if (caseId && !/^[1-3]$/.test(caseId)) caseId = ''; + ensurePostCaseLobbyMapLoaded(); + const mdBefore = getLobbyLayoutMapForSpace(space); + if (mdBefore && mdBefore.gameType === 'quiz') { + const qErr = validateQuizMap(mdBefore); + if (qErr) return reply({ ok: false, error: qErr }); + clearSpaceQuizTimers(space); + startQuizGame(sid, space, mdBefore); + io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); + return reply({ ok: true }); + } + const detectiveB = !!(data && data.detectiveLobbyBStart); + if (detectiveB && (!lobbyLevel || !caseId)) { + return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); + } + const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; + const isNamedLobbyA = mapNameNorm === 'lobbya'; + const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); + /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ + const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) + && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) + && ( + mdBefore.gameType === 'lobby' + || isNamedLobbyA + || (detectiveB && notZepOrFrogger) + )); + if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { + return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); + } + if (!lobbyHostStandingInStartArea(space, socket.id)) { + return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); + } + if (isLobbyADetectiveStart) { + assignDetectiveSuspectCardMinigames(space); + space.mapId = POST_CASE_LOBBY_SPACE_ID; + space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); + const caseNickWl = new Set(); + space.peers.forEach((p) => { + caseNickWl.add(normalizeLobbyNickname(p.nickname)); + }); + space.caseParticipantNicknames = caseNickWl; + space.joinLocked = true; + space.troublesomeOfferSent = false; + space.troublesomeTargetId = null; + space.troublesomeResponseReceived = false; + space.suspectPhaseActive = false; + space.suspectPickIndex = 0; + if (space.troublesomeEligible) space.troublesomeEligible.clear(); + else space.troublesomeEligible = new Set(); + if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); + if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); + space.troublesomeDebTimer = null; + space.troublesomeMaxTimer = null; + initTroublesomeState(space); + let li = 0; + space.peers.forEach((p) => { + const sp = pickSpawnForJoin(space.mapData, li++); + p.x = sp.x; + p.y = sp.y; + p.direction = 'down'; + }); + const peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null + })); + io.to(sid).emit('game-start', { + stayInRoomLobby: true, + mapId: POST_CASE_LOBBY_SPACE_ID, + lobbyLevel, + caseId, + peersSnap, + cardMinigames: space.suspectCardMinigames || [], + suspectProgress: space.suspectProgress || [0, 0, 0], + }); + return reply({ ok: true }); + } + const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; + let peersSnap = null; + let gauntletEndsAtEmit = undefined; + if (mapId && maps.has(mapId)) { + const prevMapIdForReturn = space.mapId; + space.mapId = mapId; + space.mapData = maps.get(mapId); + const md = space.mapData; + if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + space.peers.forEach((p) => { + const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); + p.x = sn.x; + p.y = sn.y; + }); + } + if (!md || md.gameType !== 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = null; + space.gauntletRun = null; + } + if (md && md.gameType === 'gauntlet') { + stopGauntletTicker(space); + space.gauntletReturnMapId = prevMapIdForReturn; + initGauntletPeersAll(space, md); + ensureGauntletEndsAtIfNeeded(space); + startGauntletTicker(sid, space); + gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null + ? space.gauntletRun.endsAt + : null; + peersSnap = [...space.peers.values()].map((p) => ({ + id: p.id, + x: p.x, + y: p.y, + direction: p.direction || 'down', + nickname: p.nickname, + ready: !!p.ready, + characterId: p.characterId ?? null, + gauntletJumpTicks: p.gauntletJumpTicks || 0, + gauntletScore: p.gauntletScore || 0, + gauntletEliminated: !!p.gauntletEliminated, + })); + } + if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } + } + const gameStartPayload = { + mapId: mapId || undefined, + lobbyLevel: lobbyLevel || undefined, + caseId: caseId || undefined, + peersSnap: peersSnap || undefined, + }; + if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } + io.to(sid).emit('game-start', gameStartPayload); + return reply({ ok: true }); + } + } + reply({ ok: false, error: 'ไม่ใช่ host' }); + }); + + socket.on('disconnect', () => { + let sid = socket.data.spaceId; + let space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) { + for (const [s, sp] of spaces) { + if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } + } + } + if (!space || !space.peers.has(socket.id)) return; + const wasHost = space.hostId === socket.id; + if (space.voiceInits) delete space.voiceInits[socket.id]; + if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id); + if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id]; + if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id); + if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') { + delete space.quizCarryLobbyReady[socket.id]; + if (spaceAllowsQuizCarryLobbyRelaxed(space)) { + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + } + } + if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { + delete space.gauntletCrownLobbyReady[socket.id]; + if (isCrownLobbyShellSpace(space)) { + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + } + } + space.peers.delete(socket.id); + if (space.peers.size === 0) { + stopGauntletTicker(space); + clearSpaceQuizTimers(space); + space.quizSession = null; + /* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */ + const keepCaseSpace = !!(space.detectiveMinigameActive + || (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0)); + if (keepCaseSpace) { + space.hostId = null; + return; + } + spaces.delete(sid); + } else { + if (wasHost) { + const firstPeer = space.peers.values().next().value; + if (firstPeer) { + space.hostId = firstPeer.id; + io.to(sid).emit('host-changed', { hostId: firstPeer.id }); + } + } + io.to(sid).emit('user-left', { id: socket.id }); + } + }); + + socket.on('gauntlet-jump', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return; + if ((p.gauntletJumpTicks || 0) > 0) return; + p.gauntletJumpPending = true; + }); + + socket.on('gauntlet-crown-begin-run', (cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); + const gr = space.gauntletRun; + if (!gr) return reply({ ok: false }); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space); + const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space); + if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false }); + const previewRoom = !!(space.previewEditorTest + || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); + if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' }); + if (!gr.crownRunHeld) return reply({ ok: true, already: true }); + gr.crownRunHeld = false; + if (okGauntletCrown) { + ensureGauntletEndsAtIfNeeded(space); + emitGauntletSync(sid, space); + } else { + emitBalloonBossMissionShellGauntletSync(sid, space); + } + return reply({ ok: true }); + }); + + /** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */ + socket.on('gauntlet-preview-rows', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md || md.gameType !== 'gauntlet') return; + if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; + const hh = md.height || 15; + if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map(); + const raw = data && Array.isArray(data.ys) ? data.ys : []; + const set = new Set(); + const cap = Math.min(raw.length, 48); + for (let i = 0; i < cap; i++) { + const fy = Math.floor(Number(raw[i])); + if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy); + } + space.gauntletPreviewRowsBySocket.set(socket.id, set); + }); + + socket.on('quiz-carry-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') { + space.quizCarryLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + socket.emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; + if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; + } + space.quizCarryLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); + }); + + socket.on('quiz-carry-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const rm = space.quizCarryLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('quiz-carry-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('gauntlet-crown-lobby-sync-request', () => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') { + space.gauntletCrownLobbyReady = {}; + } + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + socket.emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-ready', (data) => { + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return; + if (!isCrownLobbyShellSpace(space)) return; + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + for (const id of space.peers.keys()) { + if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; + } + space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready); + io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); + }); + + socket.on('gauntlet-crown-lobby-start', (_data, cb) => { + const reply = typeof cb === 'function' ? cb : () => {}; + const sid = socket.data.spaceId; + const space = sid ? spaces.get(sid) : null; + if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); + if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' }); + if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); + const gr = space.gauntletRun; + if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' }); + const rm = space.gauntletCrownLobbyReady || {}; + const humanIds = [...space.peers.keys()]; + const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); + if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); + io.to(sid).emit('gauntlet-crown-lobby-started', {}); + reply({ ok: true }); + }); + + socket.on('move', (data) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (md && md.gameType === 'gauntlet') { + const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId }; + socket.emit('user-move', out); + break; + } + let nx = data.x; + let ny = data.y; + const sess = space.quizSession; + const mdQuiz = (sess && sess.quizMapMd) || md; + if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') { + const st = sess.players && sess.players[socket.id]; + if (st && !st.eliminated) { + const tx = Math.floor(Number(nx)); + const ty = Math.floor(Number(ny)); + const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty); + const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty); + if (blockTrue || blockFalse) { + nx = p.x; + ny = p.y; + } + } + } + if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) { + const txN = Number(nx); + const tyN = Number(ny); + if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + } + if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { + const txN = Number(nx); + const tyN = Number(ny); + if (!Number.isFinite(txN) || !Number.isFinite(tyN)) { + nx = p.x; + ny = p.y; + } else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) { + nx = p.x; + ny = p.y; + } + const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny)); + nx = sn.x; + ny = sn.y; + } + if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) { + const ns = Math.floor(Number(data.spaceShooterScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.spaceShooterScore | 0); + if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns); + } + } + if (p && md && md.gameType === 'balloon_boss' && data) { + const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + if (data.balloonBossScore != null) { + const ns = Math.floor(Number(data.balloonBossScore)); + if (Number.isFinite(ns) && ns >= 0) { + const prev = Math.max(0, p.balloonBossScore | 0); + if (ns <= prev + 35) p.balloonBossScore = Math.max(prev, ns); + } + } + if (data.balloonBossBossDmg != null) { + const nd = Math.floor(Number(data.balloonBossBossDmg)); + if (Number.isFinite(nd) && nd >= 0) { + const prevD = Math.max(0, p.balloonBossBossDmg | 0); + if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd); + } + } + if (data.balloonBossBalloons != null) { + const nb = Math.floor(Number(data.balloonBossBalloons)); + if (Number.isFinite(nb) && nb >= 0) { + const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.floor(p.balloonBossBalloons) + : bbDefaultBalloons; + if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb; + } + } + if (data.balloonBossEliminated) p.balloonBossEliminated = true; + } + if (p) { + p.x = nx; + p.y = ny; + p.direction = data.direction || p.direction; + const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId }; + if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0); + if (md && md.gameType === 'balloon_boss') { + const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); + out.balloonBossScore = Math.max(0, p.balloonBossScore | 0); + out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0); + out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) + ? Math.max(0, Math.floor(p.balloonBossBalloons)) + : bbDef; + out.balloonBossEliminated = !!p.balloonBossEliminated; + } + if (nx !== data.x || ny !== data.y) socket.emit('user-move', out); + socket.to(sid).emit('user-move', out); + } + break; + } + } + }); + + socket.on('lobby-interact', (data, ack) => { + const reply = typeof ack === 'function' ? ack : () => {}; + const tx = Math.floor(Number(data && data.x)); + const ty = Math.floor(Number(data && data.y)); + for (const [sid, space] of spaces) { + if (!space.peers.has(socket.id)) continue; + const p = space.peers.get(socket.id); + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' }); + const w = md.width || 20; + const h = md.height || 15; + if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) { + return reply({ ok: false, error: 'จุดไม่ถูกต้อง' }); + } + if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) { + return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' }); + } + const px = Math.floor(Number(p.x)); + const py = Math.floor(Number(p.y)); + const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]]; + let near = false; + for (const [dx, dy] of neighbors) { + if (px + dx === tx && py + dy === ty) { near = true; break; } + } + if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' }); + if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' }); + const nick = (p.nickname || '').trim() || 'ผู้เล่น'; + io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty }); + return reply({ ok: true }); + } + reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' }); + }); + + socket.on('chat', (text) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + const nick = space.peers.get(socket.id)?.nickname || ''; + io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick }); + break; + } + } + }); + + socket.on('voice-state', ({ micOn }) => { + for (const [sid, space] of spaces) { + if (space.peers.has(socket.id)) { + const p = space.peers.get(socket.id); + if (p) p.voiceMicOn = micOn !== false; + if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id]; + io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false }); + break; + } + } + }); + + socket.on('voice-activity', ({ level }) => { + const sid = socket.data.spaceId; + if (!sid || !spaces.get(sid)) return; + const until = Date.now() + 400; + io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until }); + }); + + socket.on('voice-init', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + if (!space.voiceInits) space.voiceInits = {}; + space.voiceInits[socket.id] = data; + io.to(sid).emit('voice-init', { from: socket.id, data }); + break; + } + } + }); + + socket.on('voice-chunk', (data) => { + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid)) { + socket.to(sid).emit('voice-chunk', { from: socket.id, data }); + break; + } + } + }); + + socket.on('webrtc-signal', (data) => { + const { to, type, sdp, candidate } = data; + if (!to) return; + for (const [sid, space] of spaces) { + if (socket.rooms.has(sid) && space.peers.has(to)) { + io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate }); + break; + } + } + }); +}); + +loadMaps(); +server.listen(PORT, () => console.log('Game', PORT)); \ No newline at end of file