diff --git a/www/html/Game/public/css/customize-popup.css b/www/html/Game/public/css/customize-popup.css index 05f31b0..db0cc0f 100644 --- a/www/html/Game/public/css/customize-popup.css +++ b/www/html/Game/public/css/customize-popup.css @@ -195,6 +195,8 @@ min-width: 0; display: flex; flex-direction: column; + z-index: 2; + pointer-events: auto; } @media (max-width: 1200px) { @@ -347,6 +349,16 @@ display: block !important; } +.customize-popup-color-btn.is-taken:not(.is-active) { + opacity: 0.32; + cursor: not-allowed; + filter: grayscale(0.45); +} + +.customize-popup-color-btn.is-taken:not(.is-active) > .customize-popup-color-swatch--hex { + border-color: rgba(255, 80, 80, 0.65); +} + .customize-popup-tabs { display: flex; align-items: flex-end; @@ -395,7 +407,26 @@ width: 100%; height: 100%; object-fit: contain; + object-position: center bottom; display: block; + pointer-events: none; + transition: filter .18s ease, opacity .18s ease; +} + +/* inactive: ลดความสว่าง/ความอิ่มสี ให้ดูเป็นแท็บที่ยังไม่เลือก */ +.customize-popup-tab-btn:not(.is-active) .customize-popup-tab-img { + filter: saturate(0.35) brightness(0.78) contrast(0.95); + opacity: 0.85; +} + +.customize-popup-tab-btn:not(.is-active):hover .customize-popup-tab-img { + filter: saturate(0.55) brightness(0.92); + opacity: 0.95; +} + +.customize-popup-tab-btn.is-active .customize-popup-tab-img { + filter: none; + opacity: 1; } .customize-popup-tab-btn-line { @@ -455,7 +486,8 @@ .customize-popup-items-wrap { position: relative; flex: 1 1 auto; - min-height: 0; + min-height: calc(180px * var(--cc-shell-scale)); + overflow: hidden; padding-right: calc(2px * var(--cc-shell-scale)); padding-left: calc(2px * var(--cc-shell-scale)); } @@ -480,15 +512,19 @@ .customize-popup-item { position: relative; - min-height: calc(171px * var(--cc-shell-scale)); + width: 100%; + aspect-ratio: 181 / 171; + min-height: 0; isolation: isolate; + overflow: hidden; } .customize-popup-item-bg { position: relative; z-index: 1; - width: calc(181px * var(--cc-shell-scale)); - height: calc(171px * var(--cc-shell-scale)); + width: 100%; + height: auto; + aspect-ratio: 181 / 171; max-width: 100%; object-fit: contain; display: block; @@ -499,35 +535,42 @@ position: absolute; z-index: 2; left: 50%; - top: calc(30px * var(--cc-shell-scale)); + top: 17.5%; transform: translateX(-50%); - width: calc(101px * var(--cc-shell-scale)); - height: calc(101px * var(--cc-shell-scale)); + width: 56%; + height: auto; + max-height: 59%; object-fit: contain; display: block; + pointer-events: none; } .customize-popup-item-price { position: absolute; - z-index: 2; + z-index: 3; left: 50%; - bottom: calc(-9px * var(--cc-shell-scale)); + bottom: -5%; transform: translateX(-50%); - width: calc(121px * var(--cc-shell-scale)); - height: calc(51px * var(--cc-shell-scale)); + width: 67%; + height: auto; + aspect-ratio: 121 / 51; object-fit: contain; display: block; + pointer-events: none; } .customize-popup-item-price-text { position: absolute; - z-index: 2; + z-index: 4; left: 54%; - bottom: calc(5px * var(--cc-shell-scale)); + bottom: 3%; transform: translateX(-50%); color: #ffffff; font-weight: 700; - font-size: calc(16px * var(--cc-shell-scale)); + font-size: clamp(10px, calc(16px * var(--cc-shell-scale)), 18px); + line-height: 1; + white-space: nowrap; + pointer-events: none; } .customize-popup-item:first-child .customize-popup-item-price, @@ -539,12 +582,13 @@ position: absolute; left: 50%; top: 50%; - width: calc(191px * var(--cc-shell-scale)); - height: calc(181px * var(--cc-shell-scale)); + width: 106%; + height: auto; + aspect-ratio: 191 / 181; transform: translate(-50%, -50%); object-fit: contain; display: none; - z-index: 1; + z-index: 5; pointer-events: none; } diff --git a/www/html/Game/public/css/evidence-view-overlay.css b/www/html/Game/public/css/evidence-view-overlay.css index 96b709a..846209d 100644 --- a/www/html/Game/public/css/evidence-view-overlay.css +++ b/www/html/Game/public/css/evidence-view-overlay.css @@ -123,17 +123,19 @@ opacity: 1; } +/* hotspots ตรงกับ mockup 06-3-evidence-view — ev-tabs canvas = 1481×129 (3 ปุ่ม 240×110 + gap 20) */ #lobby-evidence-overlay .ev-tab-click { position: absolute; - top: calc(5 / var(--ev-h) * 100%); - height: calc(110 / var(--ev-h) * 100%); - width: calc(240 / var(--ev-w) * 100%); + top: calc(5 / 129 * 100%); + height: calc(110 / 129 * 100%); + width: calc(240 / 1481 * 100%); padding: 0; margin: 0; border: none; background: transparent; cursor: pointer; border-radius: 8px; + z-index: 5; } #lobby-evidence-overlay .ev-tab-click[data-evidence-tab="0"] { @@ -141,11 +143,15 @@ } #lobby-evidence-overlay .ev-tab-click[data-evidence-tab="1"] { - left: calc(260 / var(--ev-w) * 100%); + left: calc(260 / 1481 * 100%); } #lobby-evidence-overlay .ev-tab-click[data-evidence-tab="2"] { - left: calc(520 / var(--ev-w) * 100%); + left: calc(520 / 1481 * 100%); +} + +#lobby-evidence-overlay .ev-tab-click:hover { + background: transparent; } #lobby-evidence-overlay .ev-tab-click:focus-visible { @@ -159,15 +165,23 @@ width: calc(940 / var(--ev-w) * 100%); height: calc(560 / var(--ev-h) * 100%); display: flex; - gap: calc(24 / var(--ev-w) * 100%); + flex-wrap: nowrap; + align-content: flex-start; + align-items: stretch; + justify-content: flex-start; + gap: calc(24 / 940 * 100%); pointer-events: none; } +/* การ์ดเต็ม — อิง mockup 06-3 (296×540 ในกรอบ 940×560) */ #lobby-evidence-overlay .ev-card { position: relative; - flex: 1 1 0; + flex: 0 0 calc(296 / 940 * 100%); + width: calc(296 / 940 * 100%); + max-width: calc(296 / 940 * 100%); min-width: 0; height: 100%; + max-height: 100%; border-radius: 14px; background: linear-gradient(180deg, #cfd5dd 0%, #b2bac4 100%); box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); @@ -175,6 +189,7 @@ flex-direction: column; overflow: hidden; pointer-events: auto; + box-sizing: border-box; } #lobby-evidence-overlay .ev-card.rarity-common { @@ -235,9 +250,9 @@ } #lobby-evidence-overlay .ev-card-art { - flex: 1 1 auto; - min-height: 120px; - margin: 8px; + flex: 0 0 calc(290 / 540 * 100%); + min-height: 0; + margin: 8px 8px 0; border-radius: 8px; background: #707a8a; color: rgba(255, 255, 255, 0.55); @@ -246,6 +261,7 @@ display: grid; place-items: center; text-align: center; + box-sizing: border-box; } #lobby-evidence-overlay .ev-card-desc { @@ -254,9 +270,11 @@ font-weight: 500; color: #2a2f3c; line-height: 1.35; - flex-shrink: 0; + flex: 1 1 auto; + min-height: 0; text-align: center; margin: 0; + overflow: hidden; } #lobby-evidence-overlay .ev-card-link-wrap { @@ -329,3 +347,59 @@ clip: rect(0, 0, 0, 0); white-space: nowrap; } + +/* ===== การ์ดที่ยังไม่ได้รับ — locked overlay ===== */ +#lobby-evidence-overlay .ev-card.ev-card--locked { + filter: grayscale(0.85) brightness(0.78); + position: relative; +} + +#lobby-evidence-overlay .ev-card.ev-card--locked > *:not(.ev-card-lock) { + opacity: 0.18; +} + +#lobby-evidence-overlay .ev-card-lock { + position: absolute; + inset: 0; + display: grid; + align-content: center; + justify-items: center; + gap: 10px; + padding: 16px; + background: linear-gradient(180deg, rgba(8, 14, 32, 0.55) 0%, rgba(8, 14, 32, 0.85) 100%); + text-align: center; + z-index: 3; + border-radius: 10px; +} + +#lobby-evidence-overlay .ev-card-lock-icon { + font-size: 64px; + line-height: 1; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.6)); + color: #cbd5e1; +} + +#lobby-evidence-overlay .ev-card-lock-text { + font-size: 18px; + font-weight: 800; + color: #e2e8f0; + letter-spacing: 0.04em; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.6); +} + +#lobby-evidence-overlay .ev-card-lock-hint { + font-size: 12px; + font-weight: 500; + color: #94a3b8; + line-height: 1.4; + max-width: 90%; +} + +#lobby-evidence-overlay .ev-empty-hint { + grid-column: 1 / -1; + margin: 0; + padding: 48px 24px; + text-align: center; + color: #94a3b8; + font: 500 15px/1.5 Kanit, system-ui, sans-serif; +} diff --git a/www/html/Game/public/css/testimony-overlay.css b/www/html/Game/public/css/testimony-overlay.css index cb8839c..325d69c 100644 --- a/www/html/Game/public/css/testimony-overlay.css +++ b/www/html/Game/public/css/testimony-overlay.css @@ -9,11 +9,8 @@ position: fixed; inset: 0; z-index: 9000; - display: flex; - align-items: flex-start; - justify-content: center; overflow: hidden; - font-family: 'Kanit', 'Noto Sans Thai', system-ui, sans-serif; + font-family: 'Kanit', 'Noto Sans Thai', 'Noto Sans Thai', system-ui, sans-serif; color: #e7ecff; background: #04050d; } @@ -23,22 +20,19 @@ } #testimony-overlay .stage-wrap { - position: relative; - width: 100%; - height: 100%; - min-height: 100dvh; - display: flex; - align-items: flex-start; - justify-content: center; + position: absolute; + inset: 0; overflow: hidden; } #testimony-overlay .es3-stage { - position: relative; - width: calc(var(--es3-w) * 1px); - height: calc(var(--es3-h) * 1px); - flex-shrink: 0; - transform-origin: top center; + position: absolute; + left: 0; + top: 0; + width: 1920px; + height: 1080px; + transform-origin: 0 0; + will-change: transform; background: #04050d; } @@ -70,12 +64,12 @@ object-fit: cover; } -/* ส่วนบน: popup CSS */ +/* ส่วนบน: popup CSS — พิกัด px ตรง evidence-summary-3.css */ #testimony-overlay .es3-top-popup { - left: calc(50 / var(--es3-w) * 100%); - top: calc(25 / var(--es3-h) * 100%); - width: calc(1820 / var(--es3-w) * 100%); - height: calc(295 / var(--es3-h) * 100%); + left: 50px; + top: 25px; + width: 1820px; + height: 295px; background: linear-gradient(180deg, rgba(28, 50, 90, 0.55) 0%, rgba(15, 30, 60, 0.55) 100%); border: 2px solid #5bd8ff; border-radius: 26px; @@ -86,39 +80,39 @@ #testimony-overlay .es3-top-popup .corner { position: absolute; - width: calc(86 / var(--es3-w) * 100%); - height: calc(86 / var(--es3-h) * 100%); + width: 86px; + height: 86px; pointer-events: none; } #testimony-overlay .es3-top-popup .c-tl { - top: calc(-5 / var(--es3-h) * 100%); - left: calc(-5 / var(--es3-w) * 100%); + top: -5px; + left: -5px; border-top-left-radius: 26px; } #testimony-overlay .es3-top-popup .c-tr { - top: calc(-5 / var(--es3-h) * 100%); - right: calc(-5 / var(--es3-w) * 100%); + top: -5px; + right: -5px; border-top-right-radius: 26px; } #testimony-overlay .es3-top-popup .c-bl { - bottom: calc(-5 / var(--es3-h) * 100%); - left: calc(-5 / var(--es3-w) * 100%); + bottom: -5px; + left: -5px; border-bottom-left-radius: 26px; } #testimony-overlay .es3-top-popup .c-br { - bottom: calc(-5 / var(--es3-h) * 100%); - right: calc(-5 / var(--es3-w) * 100%); + bottom: -5px; + right: -5px; border-bottom-right-radius: 26px; } #testimony-overlay .es3-popup-tab { position: absolute; left: 50%; - top: calc(-22 / var(--es3-h) * 100%); + top: -22px; transform: translateX(-50%); padding: 8px 70px; background: linear-gradient(180deg, #2a5588 0%, #1a3a6a 100%); @@ -136,10 +130,10 @@ /* suspect */ #testimony-overlay .es3-suspect { - left: calc(90 / var(--es3-w) * 100%); - top: calc(38 / var(--es3-h) * 100%); - width: calc(215 / var(--es3-w) * 100%); - height: calc(213 / var(--es3-h) * 100%); + left: 90px; + top: 38px; + width: 215px; + height: 213px; z-index: 5; } @@ -157,10 +151,10 @@ #testimony-overlay .es3-suspect-portrait { position: absolute; - left: calc(26 / 215 * 100%); - top: calc(36 / 213 * 100%); - width: calc(163 / 215 * 100%); - height: calc(138 / 213 * 100%); + left: 26px; + top: 36px; + width: 163px; + height: 138px; border-radius: 4px; overflow: hidden; z-index: 1; @@ -176,9 +170,9 @@ #testimony-overlay .es3-suspect-name { position: absolute; - left: calc(20 / 215 * 100%); - right: calc(20 / 215 * 100%); - bottom: calc(12 / 213 * 100%); + left: 20px; + right: 20px; + bottom: 12px; text-align: center; color: #cfeeff; font-weight: 800; @@ -191,14 +185,14 @@ /* title */ #testimony-overlay .es3-title-wrap { - left: calc(320 / var(--es3-w) * 100%); - top: calc(72 / var(--es3-h) * 100%); - width: calc(1500 / var(--es3-w) * 100%); - height: calc(50 / var(--es3-h) * 100%); + left: 320px; + top: 72px; + width: 1500px; + height: 50px; display: flex; align-items: center; justify-content: center; - z-index: 4; + z-index: 5; pointer-events: none; } @@ -215,15 +209,16 @@ /* player status row */ #testimony-overlay .es3-ps-row { - left: calc(320 / var(--es3-w) * 100%); - top: calc(148 / var(--es3-h) * 100%); - width: calc(1500 / var(--es3-w) * 100%); - height: calc(170 / var(--es3-h) * 100%); + left: 320px; + top: 148px; + width: 1500px; + height: 170px; display: grid; grid-template-columns: repeat(5, 1fr); - column-gap: calc(24 / var(--es3-w) * 100%); - padding: 0 calc(30 / var(--es3-w) * 100%); - z-index: 4; + column-gap: 24px; + padding: 0 30px; + box-sizing: border-box; + z-index: 5; } #testimony-overlay .es3-ps { @@ -232,6 +227,7 @@ flex-direction: column; align-items: center; gap: 6px; + min-width: 0; } #testimony-overlay .es3-ps-name { @@ -526,19 +522,28 @@ cursor: not-allowed; } +/* โซนคลิกทับปุ่ม "ส่งหลักฐาน" ใน tab-01.png — ไม่วาด btn-send ซ้ำ (เดิม 920×944 ทับผิดจุด) */ #testimony-overlay .es3-btn-submit { - left: calc(920 / var(--es3-w) * 100%); - top: calc(944 / var(--es3-h) * 100%); - width: calc(234 / var(--es3-w) * 100%); - height: calc(100 / var(--es3-h) * 100%); - max-width: 234px; - max-height: 100px; - min-width: 120px; - min-height: 48px; - background: url('/Main-Lobby/IMAGE/Showcard/btn-send.png') center / contain no-repeat; + left: calc(708 / var(--es3-w) * 100%); + top: calc(727 / var(--es3-h) * 100%); + width: calc(180 / var(--es3-w) * 100%); + height: calc(77 / var(--es3-h) * 100%); + max-width: 180px; + max-height: 77px; + min-width: 96px; + min-height: 40px; + background: transparent; z-index: 5; } +#testimony-overlay .es3-btn-submit:not(:disabled):hover { + filter: brightness(1.08); +} + +#testimony-overlay .es3-btn-submit:not(:disabled):active { + filter: brightness(0.92); +} + #testimony-overlay .es3-btn-open { left: calc(1329 / var(--es3-w) * 100%); top: calc(944 / var(--es3-h) * 100%); @@ -607,10 +612,10 @@ } #testimony-overlay .es-reveal-wrap .es-popup { - left: calc(120 / var(--es3-w) * 100%); - top: calc(22 / var(--es3-h) * 100%); - width: calc(1680 / var(--es3-w) * 100%); - height: calc(1036 / var(--es3-h) * 100%); + left: 120px; + top: 22px; + width: 1680px; + height: 1036px; pointer-events: none; z-index: 1; } @@ -622,10 +627,10 @@ } #testimony-overlay .es-reveal-wrap .es-suspect { - left: calc(150 / var(--es3-w) * 100%); - top: calc(35 / var(--es3-h) * 100%); - width: calc(230 / var(--es3-w) * 100%); - height: calc(228 / var(--es3-h) * 100%); + left: 150px; + top: 35px; + width: 230px; + height: 228px; z-index: 5; } @@ -643,10 +648,10 @@ #testimony-overlay .es-reveal-wrap .es-suspect-portrait { position: absolute; - left: calc(30 / 230 * 100%); - top: calc(40 / 228 * 100%); - width: calc(170 / 230 * 100%); - height: calc(150 / 228 * 100%); + left: 30px; + top: 40px; + width: 170px; + height: 150px; border-radius: 4px; overflow: hidden; z-index: 1; @@ -662,9 +667,9 @@ #testimony-overlay .es-reveal-wrap .es-suspect-name { position: absolute; - left: calc(20 / 230 * 100%); - right: calc(20 / 230 * 100%); - bottom: calc(12 / 228 * 100%); + left: 20px; + right: 20px; + bottom: 12px; text-align: center; color: #fff3a8; font-weight: 800; @@ -676,10 +681,10 @@ } #testimony-overlay .es-reveal-wrap .es-title-wrap { - left: calc(285 / var(--es3-w) * 100%); - top: calc(102 / var(--es3-h) * 100%); - width: calc(1340 / var(--es3-w) * 100%); - height: calc(175 / var(--es3-h) * 100%); + left: 285px; + top: 102px; + width: 1340px; + height: 175px; display: flex; flex-direction: column; align-items: center; @@ -713,15 +718,17 @@ } #testimony-overlay .es-reveal-wrap .es-players { - left: calc(120 / var(--es3-w) * 100%); - top: calc(300 / var(--es3-h) * 100%); - width: calc(1680 / var(--es3-w) * 100%); - height: calc(650 / var(--es3-h) * 100%); + left: 120px; + top: 300px; + width: 1680px; + height: 650px; display: grid; grid-template-columns: repeat(5, 1fr); - column-gap: calc(24 / var(--es3-w) * 100%); - padding: 0 calc(70 / var(--es3-w) * 100%); - z-index: 3; + column-gap: 24px; + padding: 0 70px; + box-sizing: border-box; + align-items: start; + z-index: 6; } #testimony-overlay .es-reveal-wrap .es-player { @@ -731,6 +738,7 @@ align-items: center; gap: 12px; min-width: 0; + align-self: start; } #testimony-overlay .es-reveal-wrap .es-player-name { @@ -763,9 +771,10 @@ #testimony-overlay .es-reveal-wrap .es-card { position: relative; - width: 100%; + width: 220px; max-width: 220px; height: 275px; + flex-shrink: 0; border-radius: 10px; background: linear-gradient(180deg, #cfd5dd 0%, #b2bac4 100%); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); @@ -905,10 +914,10 @@ } #testimony-overlay .es-reveal-wrap .es-ready { - left: calc(818 / var(--es3-w) * 100%); - top: calc(951 / var(--es3-h) * 100%); - width: calc(280 / var(--es3-w) * 100%); - height: calc(96 / var(--es3-h) * 100%); + left: 818px; + top: 951px; + width: 280px; + height: 96px; max-width: 280px; max-height: 96px; z-index: 10; diff --git a/www/html/Game/public/js/customize-popup.js b/www/html/Game/public/js/customize-popup.js index 87bb80a..e015687 100644 --- a/www/html/Game/public/js/customize-popup.js +++ b/www/html/Game/public/js/customize-popup.js @@ -567,10 +567,12 @@ function applyCustomizeSelection() { var characterId = getStoredCharacterId(); if (!characterId) return Promise.resolve(null); - var tint = getSelectedTint(); - var domAvatar = resolveDomAvatarSource(); - var fallbackSrc = domAvatar.src || ''; - return composeTintedCharacterPreview(characterId, tint) + return new Promise(function (resolve) { + function runApply() { + var tint = getSelectedTint(); + var domAvatar = resolveDomAvatarSource(); + var fallbackSrc = domAvatar.src || ''; + composeTintedCharacterPreview(characterId, tint) .then(function (outSrc) { if (outSrc) return outSrc; return composeHeuristicTintFromSource(fallbackSrc, tint); @@ -603,7 +605,26 @@ } catch (e2) { /* ignore */ } return finalSrc; }) - .catch(function () { return null; }); + .catch(function () { return null; }) + .then(resolve); + } + if (isRoomLobbyPage() && typeof window.validateLobbyThemeColorChange === 'function') { + window.validateLobbyThemeColorChange( + clampIndex(state.activeColor, 1, 8), + clampIndex(state.activeSkin, 1, 3), + function (ok, err) { + if (!ok) { + if (err) try { alert(err); } catch (e) { /* ignore */ } + resolve(null); + return; + } + runApply(); + } + ); + return; + } + runApply(); + }); } function removeLegacyCustomizeOverlays() { @@ -777,19 +798,28 @@ refs.colorRow.textContent = ''; for (var i = 1; i <= 8; i += 1) { var hex = THEME_SWATCH_HEX[i - 1] || '#ffffff'; + var taken = isRoomLobbyPage() && typeof window.isLobbyThemeIndexAvailable === 'function' + && !window.isLobbyThemeIndexAvailable(i, true); var btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'customize-popup-color-btn' + (state.activeColor === i ? ' is-active' : ''); - btn.setAttribute('aria-label', 'เลือกสีที่ ' + i); + var cls = 'customize-popup-color-btn'; + if (state.activeColor === i) cls += ' is-active'; + if (taken) cls += ' is-taken'; + btn.className = cls; + btn.setAttribute('aria-label', taken ? ('สีที่ ' + i + ' มีคนใช้แล้ว') : ('เลือกสีที่ ' + i)); btn.setAttribute('aria-pressed', state.activeColor === i ? 'true' : 'false'); + if (taken) btn.setAttribute('aria-disabled', 'true'); btn.innerHTML = swatchButtonHtml(hex, state.activeColor === i); - (function (idx) { + (function (idx, isTaken) { btn.addEventListener('click', function () { + if (isRoomLobbyPage() && typeof window.isLobbyThemeIndexAvailable === 'function') { + if (!window.isLobbyThemeIndexAvailable(idx, true)) return; + } state.activeColor = idx; renderColorRow(); renderPreview(); }); - })(i); + })(i, taken); refs.colorRow.appendChild(btn); } } @@ -818,10 +848,11 @@ } } - function tabImageName(index, isActive) { - if (index === 1) return isActive ? 'tab-1-face-a.png' : 'tab-1-face.png'; - if (index === 2) return isActive ? 'tab-2-hair-a.png' : 'tab-2-hair.png'; - return isActive ? 'tab-3-cloth-a.png' : 'tab-3-cloth.png'; + /** ใช้รูปปุ่มเดี่ยว 256×100 (-a.png) ทุก state — ไฟล์ tab-N.png เป็นแถบ 3 ปุ่มของ mockup ใช้เป็น sprite ปุ่มเดี่ยวไม่ได้ */ + function tabImageName(index) { + if (index === 1) return 'tab-1-face-a.png'; + if (index === 2) return 'tab-2-hair-a.png'; + return 'tab-3-cloth-a.png'; } function renderTabs() { @@ -841,7 +872,7 @@ btn.className = 'customize-popup-tab-btn' + (active ? ' is-active' : ''); btn.setAttribute('aria-label', 'หมวด ' + i); btn.innerHTML = '' + - '' + + '' + ''; (function (idx) { btn.addEventListener('click', function () { @@ -1029,6 +1060,7 @@ syncScale(); syncScrollUi(); renderPreview(); + if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); requestAnimationFrame(syncScale); requestAnimationFrame(syncScrollUi); } @@ -1105,4 +1137,5 @@ window.openCustomizePopup = openPopup; window.closeCustomizePopup = closePopup; + window.refreshCustomizeColorRow = renderColorRow; })(); diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 0cae327..15ab2f5 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -328,6 +328,13 @@ }; if (socket && socket.connected) { socket.emit('detective-minigame-finished', {}, function (res) { + try { + if (res && Array.isArray(res.myPlayerEvidence)) { + var hasAny = res.myPlayerEvidence.some(function (r) { return Array.isArray(r) && r.length; }); + if (hasAny) sessionStorage.setItem('justiceDetectiveMyEvidence', JSON.stringify(res.myPlayerEvidence)); + else sessionStorage.removeItem('justiceDetectiveMyEvidence'); + } + } catch (e) { /* ignore */ } go(); }); } else { @@ -800,8 +807,57 @@ return gauntletCrownRunwayBgImagesReadyPlay(); } + /** ตำแหน่งเส้น FINISH บนจอ (0=ซ้าย 1=ขวา) จากขอบซ้าย viewport — ใช้หยุดเลื่อน + fade สิ่งกีดขวาง */ + function gauntletCrownRunwayFinishLineScreenFracPlay(g) { + if (!g) return null; + const raw = mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let lineInArt = Number(raw.finishLineInFinishArtFrac); + if (!Number.isFinite(lineInArt)) { + /* mno9kb07: จบที่ประตูตู้เซฟ (ขวาของ finish art) ไม่ใช่แถบ FINISH ด้านซ้าย */ + lineInArt = gauntletCrownRunwayBgMapEligiblePlay() ? 0.72 : 0.12; + } + lineInArt = Math.max(0, Math.min(0.92, lineInArt)); + const finishLineWorldX = g.finishStart + g.w4Safe * lineInArt; + return (finishLineWorldX - g.scrollView) / Math.max(1e-6, g.cwWorld); + } + + function gauntletCrownRunwayFinishStopViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + const stopHi = gauntletCrownRunwayBgMapEligiblePlay() ? 0.85 : 0.75; + let vp = Number(raw.finishStopViewportFrac); + if (Number.isFinite(vp)) return Math.max(0.08, Math.min(stopHi, vp)); + let legacy = Number(raw.finishStopScreenFrac); + if (Number.isFinite(legacy)) { + legacy = Math.max(0.08, Math.min(0.96, legacy)); + return Math.max(0.12, Math.min(0.62, 0.14 + legacy * 0.42)); + } + return gauntletCrownRunwayBgMapEligiblePlay() ? 0.5 : 0.34; + } + + function gauntletCrownRunwayObstacleFadeStartViewportFracPlay() { + const raw = mapData && mapData.gauntletCrownRunwayBg && typeof mapData.gauntletCrownRunwayBg === 'object' ? mapData.gauntletCrownRunwayBg : {}; + let v = Number(raw.obstacleFadeStartViewportFrac); + if (!Number.isFinite(v)) v = 0.9; + return Math.max(0.45, Math.min(1.15, v)); + } + + /** 1 = สิ่งกีดขวางเต็ม · 0 = หาย — ค่อยๆ จางเมื่อเส้น FINISH เข้าใกล้จุดหยุดบนจอ */ + function gauntletCrownRunwayObstacleDrawAlphaPlay() { + if (!gauntletCrownRunwayBgDrawActivePlay()) return 1; + if (gauntletCrownRunwayBgFinishLatched) return 0; + const g = gauntletCrownRunwayScrollGeometryPlay(); + if (!g) return 1; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return 1; + const stopAt = gauntletCrownRunwayFinishStopViewportFracPlay(); + const fadeStart = gauntletCrownRunwayObstacleFadeStartViewportFracPlay(); + if (screenFrac >= fadeStart) return 1; + if (screenFrac <= stopAt) return 0; + return (screenFrac - stopAt) / Math.max(1e-6, fadeStart - stopAt); + } + function gauntletCrownRunwayBgHideObstaclesPlay() { - return gauntletCrownRunwayBgDrawActivePlay() && gauntletCrownRunwayBgFinishLatched; + return gauntletCrownRunwayObstacleDrawAlphaPlay() <= 0.001; } /** Last Light: หยุดแอนิเมชันวิ่งเมื่อหยุดที่ FINISH หรือ latch แล้ว */ @@ -979,7 +1035,9 @@ /** * Shared runway strip math (viewport in strip space). null if not drawable. - * Map tuning: gauntletCrownRunwayBg.finishStopScreenFrac 0.08–0.96 = จุดกลางจอต้องถึงภายในแถบ FINISH ก่อนหยุด (ค่าเริ่ม ~0.56 ใกล้กล่องหยุดขวาเส้น FINISH บนงานศิลป์) + * Map tuning (mno9kb07): finishStopViewportFrac 0.08–0.75 = เส้น FINISH อยู่ที่สัดส่วนนี้จากซ้ายจอก่อนหยุด (ค่าเริ่ม 0.34) + * finishLineInFinishArtFrac = ตำแหน่งเส้นใน finish.png (ค่าเริ่ม 0.12) + * obstacleFadeStartViewportFrac = เริ่มจางสิ่งกีดขวางเมื่อเส้น FINISH อยู่ที่สัดส่วนนี้บนจอ (ค่าเริ่ม 0.9) */ function gauntletCrownRunwayScrollGeometryPlay() { if (!mapData || !canvas || !gauntletCrownRunwayBgDrawActivePlay()) return null; @@ -1007,19 +1065,14 @@ }; } - /** True when viewport center crosses “หยุดที่ FINISH” (ไม่ใช้แค่ขอบขวาถึงปลายแทร็ก) */ + /** True when เส้น FINISH ถึงตำแหน่งบนจอที่กำหนด (ขวาเส้น 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; + const screenFrac = gauntletCrownRunwayFinishLineScreenFracPlay(g); + if (screenFrac == null) return false; + return screenFrac <= gauntletCrownRunwayFinishStopViewportFracPlay() + 0.008; } function resetGauntletCrownRunwaySpawnScrollSnap() { @@ -1728,6 +1781,56 @@ } const LOBBY_PLAYER_TINT_KEY = 'justiceLobbyPlayerTint'; + /** ธีมสี 8 ช่อง — ตรง room-lobby / customize */ + const PLAY_LOBBY_THEME_HEX = [ + '#d72520', '#ef8508', '#efe237', '#5bb443', + '#2585cb', '#3f4ead', '#b53fd6', '#fec1fe', + ]; + const PLAY_LOBBY_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; + let playLobbyBotThemes = []; + + function parsePlayLobbyThemeIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 8) ? v : null; + } + + function parsePlayLobbySkinIndex(n) { + const v = Math.floor(Number(n)); + return (v >= 1 && v <= 3) ? v : null; + } + + function playTintFromLobbyThemeIndices(themeIndex, skinIndex) { + const ti = parsePlayLobbyThemeIndex(themeIndex); + const si = parsePlayLobbySkinIndex(skinIndex); + const bodyHex = ti ? PLAY_LOBBY_THEME_HEX[ti - 1] : ''; + const headHex = si ? PLAY_LOBBY_SKIN_HEX[si - 1] : ''; + if (!bodyHex && !headHex) return null; + return playTintFromLobbyRgb(bodyHex || null, headHex || null); + } + + function playTintFromJoinPeer(peerRow) { + if (!peerRow) return null; + return playTintFromLobbyThemeIndices(peerRow.lobbyColorThemeIndex, peerRow.lobbySkinToneIndex); + } + + function playTintForPreviewBotId(peerId) { + if (!isPreviewBotId(peerId)) return null; + const idx = parseInt(String(peerId).slice(PREVIEW_BOT_PREFIX.length), 10); + if (!Number.isFinite(idx) || idx < 0) return null; + const slot = playLobbyBotThemes[idx]; + if (!slot) return null; + const tint = playTintFromLobbyThemeIndices(slot.themeIndex, slot.skinToneIndex); + if (!tint) return null; + try { + const map = JSON.parse(sessionStorage.getItem(lobbyBotTintsStorageKeyPlay()) || '{}'); + map['__lobby_bot_' + idx] = { + colorTheme: PLAY_LOBBY_THEME_HEX[parsePlayLobbyThemeIndex(slot.themeIndex) - 1] || '', + colorSkin: PLAY_LOBBY_SKIN_HEX[parsePlayLobbySkinIndex(slot.skinToneIndex) - 1] || '', + }; + sessionStorage.setItem(lobbyBotTintsStorageKeyPlay(), JSON.stringify(map)); + } catch (e) { /* ignore */ } + return tint; + } function lobbyBotTintsStorageKeyPlay() { const sp = (typeof spaceId === 'string' && spaceId) ? spaceId : (new URLSearchParams(window.location.search).get('space') || 'default'); @@ -1803,8 +1906,12 @@ } /** สีจากล็อบบี้ (ผู้เล่น + บอท) — fallback hash ตาม peer id */ - function resolvePlayTintForPeer(peerId) { + function resolvePlayTintForPeer(peerId, peerRow) { const sid = String(peerId || ''); + const fromRow = peerRow ? playTintFromJoinPeer(peerRow) : null; + if (fromRow) return fromRow; + const previewTint = playTintForPreviewBotId(sid); + if (previewTint) return previewTint; if (myId != null && sid === String(myId)) { const meTint = loadPlayTintFromLobbyPlayerStorage(); if (meTint) return meTint; @@ -1815,6 +1922,45 @@ return playTintFromPeerId(sid); } + function applyPlayTintFromLobbyIndices(peerId, themeIndex, skinIndex) { + const tint = playTintFromLobbyThemeIndices(themeIndex, skinIndex); + if (!tint) return; + const sid = peerId != null ? String(peerId) : ''; + if (myId != null && sid === String(myId)) { + me.playTint = tint; + return; + } + const o = others.get(peerId) || others.get(sid); + if (o) o.playTint = tint; + if (isPreviewBotId(sid)) playTintForPreviewBotId(sid); + } + + function applyPlayLobbyTintSync(data) { + if (!data || typeof data !== 'object') return; + if (Array.isArray(data.lobbyBotThemes)) playLobbyBotThemes = data.lobbyBotThemes; + if (Array.isArray(data.peers)) { + data.peers.forEach(function (p) { + if (!p || p.id == null) return; + applyPlayTintFromLobbyIndices(p.id, p.lobbyColorThemeIndex, p.lobbySkinToneIndex); + }); + } + if (playBotsEnabled()) rebalancePreviewBots(); + try { + preloadPlayOccupantsTints(); + preloadPlayLobbyIdleAvatarsForOccupants(); + } catch (e) { /* ignore */ } + } + + function getPlayTintForMissionAvatar(peerId) { + const sid = peerId != null ? String(peerId) : ''; + if (myId != null && sid === String(myId)) { + return me.playTint || resolvePlayTintForPeer(sid); + } + const o = others.get(peerId) || others.get(sid); + if (o && o.playTint) return o.playTint; + return resolvePlayTintForPeer(sid); + } + /** สรุปภารกิจ — ผู้เล่นจริงต้องอยู่ใน top N เสมอ */ function missionEnsureHumanInTopRanked(rows, maxN) { const cap = Math.max(1, maxN || 5); @@ -4200,7 +4346,7 @@ [...others.keys()].filter(isPreviewBotId).forEach((bid) => { const o = others.get(bid); if (!o) return; - o.playTint = resolvePlayTintForPeer(bid); + o.playTint = playTintForPreviewBotId(bid) || resolvePlayTintForPeer(bid); if (o.botTier == null) { const tierRoll = Math.random(); const stackPrev = mapData && mapData.gameType === 'stack'; @@ -4234,7 +4380,11 @@ } botIds = [...others.keys()].filter(isPreviewBotId); while (botIds.length < wantBots) { - const id = PREVIEW_BOT_PREFIX + (++previewBotSeq); + /* หา slot ว่างเล็กสุด (0,1,2,…) — IDX ของ ID ต้องตรงกับ playLobbyBotThemes[idx] เพื่อให้สีตรง lobby */ + let nextSlot = 0; + while (others.has(PREVIEW_BOT_PREFIX + nextSlot)) nextSlot++; + previewBotSeq = Math.max(previewBotSeq, nextSlot + 1); + const id = PREVIEW_BOT_PREFIX + nextSlot; const joinIdx = countPlayHumans() + botIds.length; let x; let y; @@ -4267,7 +4417,7 @@ nickname: 'บอท', characterId: pickPreviewBotCharacterId(botIds.length), spawnJoinOrder: joinIdx, - playTint: resolvePlayTintForPeer(id), + playTint: playTintForPreviewBotId(id) || resolvePlayTintForPeer(id), botTier, gauntletJumpTicks: 0, gauntletJumpVis: 0, @@ -8189,13 +8339,7 @@ if (fallbackUrl) imgEl.src = fallbackUrl; return; } - const isMePeer = peerId != null && myId != null && String(peerId) === String(myId); - const lobbySrc = isMePeer ? playLobbyIdleAvatarSrcForCharacter(characterId) : ''; - if (lobbySrc) { - imgEl.src = lobbySrc; - return; - } - const tint = resolvePlayTintForPeer(peerId != null ? String(peerId) : String((myId != null && myId !== '') ? myId : 'local')); + const tint = getPlayTintForMissionAvatar(peerId != null ? peerId : ((myId != null && myId !== '') ? myId : 'local')); const out = resolvePlayDrawCharSource(characterId, 'down', nowT, false, tint, isMePeer); if (out && out.tagName === 'CANVAS' && out.width > 0) { try { @@ -12207,6 +12351,7 @@ const [sx, sy] = worldToScreen(ent.spaceShooterCx, ent.spaceShooterCy); const col = SPACE_SHOOTER_SHIP_COLORS[idx % SPACE_SHOOTER_SHIP_COLORS.length]; const elimShip = !!(isSpaceShooterMissionUiMapPlay() && ent.spaceShooterEliminated); + if (elimShip) return; const slot = Math.max(1, Math.min(6, Number(ent.spaceShooterSlot) || ((idx % 6) + 1))); const shipUrl = (playSpaceShooterShipImageUrls[slot - 1] || '').trim(); const shipRec = shipUrl ? ensureGauntletAssetImage(shipUrl) : null; @@ -14477,7 +14622,21 @@ gauntletObsRenderPrev = []; gauntletObsRenderNext = []; gauntletObsBlendT0 = 0; - me.playTint = resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local'))); + if (Array.isArray(res.lobbyBotThemes)) playLobbyBotThemes = res.lobbyBotThemes; + const myPeerJoin = (Array.isArray(plist) ? plist : []).find((q) => q && q.id === myId); + me.playTint = playTintFromJoinPeer(myPeerJoin) || resolvePlayTintForPeer(String((myId != null && myId !== '') ? myId : (nick || 'local')), myPeerJoin); + if (myPeerJoin && myPeerJoin.lobbyColorThemeIndex) { + try { + const ti = parsePlayLobbyThemeIndex(myPeerJoin.lobbyColorThemeIndex); + const si = parsePlayLobbySkinIndex(myPeerJoin.lobbySkinToneIndex) || 1; + if (ti) { + localStorage.setItem(LOBBY_PLAYER_TINT_KEY, JSON.stringify({ + themeRgb: PLAY_LOBBY_THEME_HEX[ti - 1], + skinRgb: PLAY_LOBBY_SKIN_HEX[si - 1], + })); + } + } catch (eTintStore) { /* ignore */ } + } // อุ่น cache เลเยอร์สีตัวละครก่อนเข้าเกม (ตัวเรา + เพื่อนร่วมห้อง) กันกระตุกตอนโหลดสีกลางเกม try { preloadPlayCharacterLayers(me.characterId || getPlayCharacterId()); @@ -14509,7 +14668,7 @@ others.set(p.id, { x: px, y: py, tx: px, ty: py, direction: p.direction, nickname: p.nickname, characterId: p.characterId, spawnJoinOrder: spawnOrd, - playTint: resolvePlayTintForPeer(p.id), + playTint: playTintFromJoinPeer(p) || playTintForPreviewBotId(p.id) || resolvePlayTintForPeer(p.id, p), gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), @@ -14882,6 +15041,8 @@ socket.on('peer-display-name', applyPlayDisplayNameUpdate); + socket.on('lobby-tint-sync', applyPlayLobbyTintSync); + window.addEventListener('jd-display-name-changed', function (ev) { const next = ev && ev.detail && ev.detail.displayName ? String(ev.detail.displayName).trim() : ''; if (!next || !myId) return; @@ -14901,7 +15062,7 @@ direction: data.direction || 'down', nickname: data.nickname, characterId: data.characterId ?? null, spawnJoinOrder: spawnOrdJ, - playTint: resolvePlayTintForPeer(data.id), + playTint: playTintFromJoinPeer(data) || playTintForPreviewBotId(data.id) || resolvePlayTintForPeer(data.id, data), gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), @@ -17978,10 +18139,13 @@ drawStackMinigame(ctx, worldToScreen, zDraw); } - const gauntletObsDraw = (isGauntlet() && !gauntletCrownRunwayBgHideObstaclesPlay()) + const gauntletObsAlpha = isGauntlet() ? gauntletCrownRunwayObstacleDrawAlphaPlay() : 1; + const gauntletObsDraw = (isGauntlet() && gauntletObsAlpha > 0.001) ? getGauntletObsDrawPositionsAt(performance.now()) : []; if (isGauntlet() && gauntletObsDraw.length) { + ctx.save(); + ctx.globalAlpha = gauntletObsAlpha; const stx = Math.max(0, Math.floor(worldMinX / tileSize)); const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize)); const sty = Math.max(0, Math.floor(worldMinY / tileSize)); @@ -18024,6 +18188,7 @@ drawGauntletLaserColumnScreen(rx, ry, rw, rh); } } + ctx.restore(); } /** ป้ายติดตัว — กึ่งกลางลำตัว (ทับอก/เอว) แบบเดียวกับป้ายบนพื้น ไม่มีเส้นเชื่อม */ diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 7ddedec..665a002 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -158,6 +158,8 @@ let suspectCardMinigames = []; /** หลักฐาน (ติกถูก) ที่เก็บได้ต่อผู้ต้องสงสัย 0–2 (เล่นมินิเกมจบ +1 ต่อช่อง สูงสุด 3) */ let suspectProgress = [0, 0, 0]; + /** การ์ดหลักฐานที่ผู้เล่นคนนี้เก็บได้ — [suspectIdx][cardIdx 0–2] จากเซิร์ฟ */ + let myPlayerEvidence = [[], [], []]; /** ขั้นพิจารณาคดี: null | 'voting' | 'revealed' */ let trialMode = null; let myTrialVote = null; @@ -414,6 +416,23 @@ } function rlResolveMyColors(cb) { + var me = peers.get(socket.id); + var serverTi = me && parseLobbyThemeIndex(me.lobbyColorThemeIndex); + var serverSi = me && parseLobbySkinIndex(me.lobbySkinToneIndex); + if (serverTi) { + applyPeerThemeFromServer(me, serverTi, serverSi, function () { + myTintTheme = me.colorTheme || myTintTheme; + myTintSkin = me.colorSkin || myTintSkin; + try { + localStorage.setItem('lobbyThemeColor', String(serverTi)); + if (serverSi) localStorage.setItem('lobbySkinTone', String(serverSi)); + } catch (eLs) { /* ignore */ } + persistLobbyPlayerTintJson(); + if (mapData && canvas) drawLobbyMap(); + if (cb) cb(); + }); + return; + } var c = '', s = ''; try { c = localStorage.getItem('lobbyThemeColor') || ''; s = localStorage.getItem('lobbySkinTone') || ''; } catch (e) {} var need = 0, done = false; @@ -538,6 +557,127 @@ ]; /** สีผิว 3 ช่อง */ var ROOM_CZ_SKIN_HEX = ['#eaa78a', '#fbd5c4', '#fae9e1']; + /** สี bot จากเซิร์ฟเวอร์ (ลำดับช่อง) — [{ themeIndex, skinToneIndex }, ...] */ + var lobbyBotThemesFromServer = []; + + function parseLobbyThemeIndex(n) { + var v = Math.floor(Number(n)); + return (v >= 1 && v <= 8) ? v : null; + } + + function parseLobbySkinIndex(n) { + var v = Math.floor(Number(n)); + return (v >= 1 && v <= 3) ? v : null; + } + + function getMyLobbyThemeIndex() { + var me = peers.get(socket.id); + return parseLobbyThemeIndex(me && me.lobbyColorThemeIndex) || parseLobbyThemeIndex(localStorage.getItem('lobbyThemeColor')); + } + + function peerLobbyThemeIndex(p) { + if (!p) return null; + return parseLobbyThemeIndex(p.lobbyColorThemeIndex) || parseLobbyThemeIndex(p.colorThemeIndex); + } + + function getLobbyTakenThemeIndices(excludePeerId) { + var taken = []; + var seen = {}; + peers.forEach(function (p, id) { + if (excludePeerId && id === excludePeerId) return; + var ti = peerLobbyThemeIndex(p); + if (ti && !seen[ti]) { seen[ti] = true; taken.push(ti); } + }); + lobbyBots.forEach(function (b) { + var ti = peerLobbyThemeIndex(b); + if (ti && !seen[ti]) { seen[ti] = true; taken.push(ti); } + }); + return taken; + } + + function isLobbyThemeIndexAvailable(themeIndex, forSelfChange) { + var ti = parseLobbyThemeIndex(themeIndex); + if (!ti) return false; + var myIdx = getMyLobbyThemeIndex(); + if (forSelfChange && myIdx === ti) return true; + return getLobbyTakenThemeIndices(forSelfChange ? socket.id : null).indexOf(ti) < 0; + } + + function applyThemeIndexToOccupant(target, themeIndex, skinIndex, done) { + if (!target) { if (done) done(); return; } + var ti = parseLobbyThemeIndex(themeIndex) || 1; + var si = parseLobbySkinIndex(skinIndex) || 1; + target.colorThemeIndex = ti; + target.colorSkinIndex = si; + var need = 2; + function tick() { + need--; + if (need <= 0 && done) done(); + } + rlSampleSwatch('color', ti, function (rgb) { + if (rgb) target.colorTheme = rgb; + tick(); + }); + rlSampleSwatch('skin', si, function (rgb) { + if (rgb) target.colorSkin = rgb; + tick(); + }); + } + + function applyPeerThemeFromServer(peer, themeIndex, skinIndex, done) { + applyThemeIndexToOccupant(peer, themeIndex, skinIndex, done); + } + + function applyAllPeerThemesFromServer(done) { + var pending = 1; + function tick() { + pending--; + if (pending <= 0 && done) done(); + } + peers.forEach(function (p, id) { + var ti = parseLobbyThemeIndex(p.lobbyColorThemeIndex); + if (!ti) return; + pending++; + applyPeerThemeFromServer(p, ti, p.lobbySkinToneIndex, tick); + if (id === socket.id) { + try { + localStorage.setItem('lobbyThemeColor', String(ti)); + if (p.lobbySkinToneIndex) localStorage.setItem('lobbySkinTone', String(p.lobbySkinToneIndex)); + } catch (e) { /* ignore */ } + } + }); + tick(); + } + + function applyLobbyTintSyncPayload(data) { + if (!data) return; + if (Array.isArray(data.lobbyBotThemes)) lobbyBotThemesFromServer = data.lobbyBotThemes; + try { sessionStorage.removeItem(lobbyBotTintsStorageKey()); } catch (eClr) { /* ignore */ } + (data.peers || []).forEach(function (row) { + if (!row || !row.id) return; + var p = peers.get(row.id); + if (!p) return; + if (parseLobbyThemeIndex(row.lobbyColorThemeIndex)) { + p.lobbyColorThemeIndex = row.lobbyColorThemeIndex; + } + if (parseLobbySkinIndex(row.lobbySkinToneIndex)) { + p.lobbySkinToneIndex = row.lobbySkinToneIndex; + } + }); + applyAllPeerThemesFromServer(function () { + if (peers.get(socket.id)) { + var me = peers.get(socket.id); + if (me && parseLobbyThemeIndex(me.lobbyColorThemeIndex)) { + myTintTheme = me.colorTheme || myTintTheme; + myTintSkin = me.colorSkin || myTintSkin; + } + } + syncLobbyCaseBots(); + if (mapData && canvas) drawLobbyMap(); + if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); + }); + } + /** จุด "ห้องแต่งตัว" ในฉาก — ตั้งจาก mapData.customizeSpot (วางใน editor) ถ้าไม่มี = null = ไม่แสดง */ var ROOM_CZ_SPOT = null; var roomCzIcon = new Image(); @@ -624,6 +764,37 @@ function setupRoomCustomize() { window.openRoomCustomize = openRoomCustomize; window.closeRoomCustomize = closeRoomCustomize; + window.isLobbyThemeIndexAvailable = isLobbyThemeIndexAvailable; + window.getLobbyTakenThemeIndices = function (forSelf) { + return getLobbyTakenThemeIndices(forSelf ? socket.id : null); + }; + window.validateLobbyThemeColorChange = function (themeIndex, skinIndex, cb) { + var ti = parseLobbyThemeIndex(themeIndex); + if (!ti) { if (cb) cb(false, 'เลือกสีเสื้อไม่ถูกต้อง'); return; } + if (!isLobbyThemeIndexAvailable(ti, true)) { + if (cb) cb(false, 'สีเสื้อนี้มีคนใช้แล้ว — เลือกสีอื่น'); + return; + } + socket.emit('lobby-tint-update', { + themeIndex: ti, + skinToneIndex: parseLobbySkinIndex(skinIndex) || 1, + }, function (res) { + if (!(res && res.ok)) { + if (cb) cb(false, (res && res.error) ? res.error : 'เปลี่ยนสีไม่สำเร็จ'); + return; + } + try { + localStorage.setItem('lobbyThemeColor', String(res.lobbyColorThemeIndex || ti)); + localStorage.setItem('lobbySkinTone', String(res.lobbySkinToneIndex || skinIndex || 1)); + } catch (e) { /* ignore */ } + var me = peers.get(socket.id); + if (me) { + me.lobbyColorThemeIndex = res.lobbyColorThemeIndex || ti; + me.lobbySkinToneIndex = res.lobbySkinToneIndex || skinIndex; + } + if (cb) cb(true); + }); + }; window.addEventListener('customize-popup:applied', function () { rlResolveMyColors(function () { persistLobbyPlayerTintJson(); @@ -1971,35 +2142,26 @@ botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 900), }; lobbyBots.set(id, bot); - var restored = applySavedLobbyBotColors(id, bot); - if (restored) { - if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); - if (mapData && canvas) drawLobbyMap(); - } else { - var botColorIdx = 1 + Math.floor(Math.random() * 8); - var botSkinIdx = 1 + Math.floor(Math.random() * 3); - var colorDone = false; - var skinDone = false; - function maybeFinishBotTint() { - if (!colorDone || !skinDone) return; - saveLobbyBotTint(id, bot); + var serverSlot = lobbyBotThemesFromServer[i]; + var serverTheme = serverSlot && parseLobbyThemeIndex(serverSlot.themeIndex); + var serverSkin = serverSlot && parseLobbySkinIndex(serverSlot.skinToneIndex); + if (serverTheme) { + applyThemeIndexToOccupant(bot, serverTheme, serverSkin || ((i % 3) + 1), function () { if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); - } - rlSampleSwatch('color', botColorIdx, function (rgb) { - colorDone = true; - if (rgb) { - bot.colorTheme = rgb; - if (mapData && canvas) drawLobbyMap(); - } - maybeFinishBotTint(); + if (mapData && canvas) drawLobbyMap(); }); - rlSampleSwatch('skin', botSkinIdx, function (rgb) { - skinDone = true; - if (rgb) { - bot.colorSkin = rgb; - if (mapData && canvas) drawLobbyMap(); - } - maybeFinishBotTint(); + } else { + var taken = {}; + getLobbyTakenThemeIndices(null).forEach(function (t) { taken[t] = true; }); + var botColorIdx = null; + for (var cTry = 1; cTry <= 8; cTry++) { + if (!taken[cTry]) { botColorIdx = cTry; break; } + } + if (!botColorIdx) botColorIdx = ((i % 8) + 1); + var botSkinIdx = (i % 3) + 1; + applyThemeIndexToOccupant(bot, botColorIdx, botSkinIdx, function () { + if (bot.characterId) preloadLobbyTintedCharacter(bot.characterId, bot.colorTheme, bot.colorSkin, function () {}); + if (mapData && canvas) drawLobbyMap(); }); } } @@ -2627,7 +2789,13 @@ if (lobbyBotSlotCount === 0 && maxPlayers > 0 && maxPlayers < 6) { lobbyBotSlotCount = 6 - maxPlayers; } - if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + if (Array.isArray(res.lobbyBotThemes)) lobbyBotThemesFromServer = res.lobbyBotThemes; + applyAllPeerThemesFromServer(function () { + rlResolveMyColors(function () { + if (lobbyBotSlotCount > 0) syncLobbyCaseBots(); + if (mapData && canvas) drawLobbyMap(); + }); + }); updatePlayersHud(); syncLobbyBUiChrome(); @@ -2646,7 +2814,7 @@ ensureReadyControlEnabled(); if (res.cardMinigames) setSuspectCardMinigames(res.cardMinigames); - if (Array.isArray(res.suspectProgress)) setSuspectProgress(res.suspectProgress); + applyPersonalSuspectState(res); serverSuspectPhaseActive = !!res.suspectPhaseActive; if (res.suspectPhaseActive) { openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); @@ -2704,9 +2872,16 @@ const LERP = 0.2; socket.on('user-joined', (data) => { peers.set(data.id, normalizeLobbyPeerFromServer(data, mapData)); - updatePlayersHud(); - renderPeers(); - redrawLobbyMap(); + applyPeerThemeFromServer(peers.get(data.id), data.lobbyColorThemeIndex, data.lobbySkinToneIndex, function () { + updatePlayersHud(); + renderPeers(); + redrawLobbyMap(); + if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); + }); + }); + + socket.on('lobby-tint-sync', function (data) { + applyLobbyTintSyncPayload(data || {}); }); socket.on('user-left', (data) => { @@ -2715,6 +2890,7 @@ updatePlayersHud(); renderPeers(); redrawLobbyMap(); + if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); }); socket.on('host-console-kicked', (data) => { @@ -2726,6 +2902,7 @@ socket.on('host-console-settings', (data) => { if (!data || typeof data !== 'object') return; if (data.maxPlayers != null) maxPlayers = Math.max(peers.size, parseInt(data.maxPlayers, 10) || peers.size); + if (Array.isArray(data.lobbyBotThemes)) lobbyBotThemesFromServer = data.lobbyBotThemes; if (data.botSlotCount != null) { lobbyBotSlotCount = Math.max(0, parseInt(data.botSlotCount, 10) || 0); syncLobbyCaseBots(); @@ -2733,6 +2910,7 @@ updatePlayersHud(); refreshHostConsoleOverlayIfOpen(); if (mapData && canvas) drawLobbyMap(); + if (typeof window.refreshCustomizeColorRow === 'function') window.refreshCustomizeColorRow(); }); socket.on('peer-ready', (data) => { @@ -2985,6 +3163,12 @@ applyDetectiveReturnToLobbyB(data || {}); }); + socket.on('lobby-evidence-sync', function (data) { + applyPersonalSuspectState(data || {}); + applySuspectProgressVisual(); + if (lobbyEvidenceSuspectIdx != null) renderLobbyEvidenceCards(lobbyEvidenceSuspectIdx); + }); + 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 }; @@ -3636,6 +3820,16 @@ return ov; } + function formatLobbyEvCardDescHtml(body) { + var text = String(body || '').trim(); + var lines = text ? text.split(/\n+/).map(function (s) { return s.trim(); }).filter(Boolean) : []; + if (!lines.length) lines.push('—'); + if (!lines.some(function (ln) { return ln.indexOf('เครื่องมือสืบ') >= 0; })) { + lines.push('เครื่องมือสืบเสาะเคยถือไว้ครอบครอง'); + } + return lines.join('
'); + } + function evLobbyEvidenceCardEl(c, linkName, cardIdx) { var rar = String(c.rarity || 'common').toLowerCase(); if (!LOBBY_EVIDENCE_RARITY[rar]) rar = 'common'; @@ -3654,8 +3848,8 @@ '
' + '' + '' + - '' + - '

' + + '' + + '
' + '