diff --git a/www/html/Game/public/TryChangeCSS/ai-admin.html b/www/html/Game/public/TryChangeCSS/ai-admin.html new file mode 100644 index 0000000..717823a --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/ai-admin.html @@ -0,0 +1,219 @@ + + + + + + ตั้งค่า AI แชท — Game + + + + +

ตั้งค่า AI แชท (Room Lobby)

+

ตั้งค่า OpenAI API Key และ Model สำหรับฟีเจอร์ "คุยกับ AI" ในห้องโถง

+ +
+

รหัสผ่านเริ่มต้น: game-ai-admin (ตั้ง env GAME_AI_ADMIN_PASSWORD เพื่อเปลี่ยน)

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

การตั้งค่าระบบ

+
+ + + เว้นว่างถ้าไม่ต้องการเปลี่ยน +
+
+ + +
+
+
+

Intent & RAG

+
+ + + เงื่อนไข: ถ้ามีข้อความ จะถูกส่งเป็น system message ให้โมเดลใช้เป็นแนวทางในการตอบ +
+
+ + เงื่อนไข: เมื่อเปิด RAG เท่านั้น ระบบจะแนบ RAG Context ด้านล่างเข้า system prompt +
+
+ + + เมื่อเปิด RAG ข้อความนี้จะถูกแนบเป็นบริบทให้ AI ใช้ประกอบการตอบ +
+
+ ลำดับการทำงาน: (1) Intent เป็น system message แรก (2) ถ้าเปิด RAG จะต่อ RAG Context เข้า system (3) ข้อความผู้ใช้เป็น user message +
+
+
+ + +
+
+
+

← กลับไปล็อบบี้

+
+ + + + + diff --git a/www/html/Game/public/TryChangeCSS/character.html b/www/html/Game/public/TryChangeCSS/character.html new file mode 100644 index 0000000..befec0b --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/character.html @@ -0,0 +1,217 @@ + + + + + + เลือกตัวละคร — Game + + + + + +
+

ตัวละคร (4 ทิศ)

+ + +
+

อัปโหลดตัวละคร (บน · ล่าง · ซ้าย · ขวา)

+

เลือกรูปทั้ง 4 ทิศ แล้วใส่ชื่อ (ไม่บังคับ) กดอัปโหลด

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

+
+ +
+

อัปโหลดตัวละครแบบเลเยอร์ (Shadow / Body / Hair / Head / Face)

+

สำหรับผู้สร้างสกิน: แยกไฟล์ตามเลเยอร์ + ทิศ ระบบจะรวมเป็นเฟรมให้เองก่อนอัปโหลด (ไม่ใส่เงา = ใช้ค่าเริ่มต้น)

+
+
+ +
+ Shadow + + Body + + + Hair + + + Head + + + Face + +
+ +
+
+ +
+ Shadow + + Body + + + Hair + + + Head + + + Face + +
+ +
+
+ +
+ Shadow + + Body + + + Hair + + + Head + + + Face + +
+ +
+
+ +
+ Shadow + + Body + + + Hair + + + Head + + + Face + +
+ +
+
+
+

ใช้ "ชื่อตัวละคร" ช่องเดียวกับด้านบน

+ +
+
+ +

เลือกตัวละครที่ใช้ในเกม

+

คลิกเลือก — ตัวนี้จะแสดงเมื่อเข้าโถงรอและเล่นเกม (เปลี่ยนทิศตามการเดิน)

+

โหลดรายการตัวละคร...

+
+ +
+ + + +
v —
+ + diff --git a/www/html/Game/public/TryChangeCSS/css/style.css b/www/html/Game/public/TryChangeCSS/css/style.css new file mode 100644 index 0000000..f23dbab --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/css/style.css @@ -0,0 +1,2894 @@ +* { box-sizing: border-box; } +@font-face { + font-family: 'NotoSansThai'; + src: + url('../../../Create%20Room/FONTS/NotoSansThai-Medium.ttf') format('truetype'), + url('/game-justic/Create%20Room/FONTS/NotoSansThai-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} +body { font-family: sans-serif; margin: 0; background: #1a1b26; color: #c0caf5; } +body.room-lobby-page, +body.room-lobby-page * { + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; +} +.container { max-width: 720px; margin: 1rem auto; padding: 1rem; } + +/* โครง Editor — แยกแถบเครื่องมือ / พื้นที่วาด */ +.editor-page { + display: block; +} + +/* Editor ฝังใน Admin (?embed=1) — เต็มความสูง iframe, พื้นที่แคนวาสใหญ่ + เลื่อนแยก */ +html.editor-embed-admin { + height: 100%; +} +html.editor-embed-admin body { + height: 100%; + margin: 0; + overflow: hidden; +} +body.editor-embed-admin .version-tag { + display: none !important; +} +body.editor-embed-admin .container.editor-page { + max-width: none !important; + width: 100%; + height: 100%; + margin: 0 !important; + padding: 0.35rem 0.5rem 0.45rem; + display: flex; + flex-direction: column; + min-height: 0; + box-sizing: border-box; +} +body.editor-embed-admin .container.editor-page > h1 { + display: none; +} +body.editor-embed-admin .editor-toolbar-wrap { + flex: 0 1 auto; + max-height: min(36vh, 360px); + overflow-x: hidden; + overflow-y: auto; + padding: 0.15rem 0.25rem 0.45rem 0; + margin: 0 0 0.3rem; + border-bottom: 1px solid rgba(65, 72, 104, 0.9); + scrollbar-width: thin; +} +body.editor-embed-admin .editor-workspace { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +body.editor-embed-admin .canvas-wrap { + flex: 1 1 auto; + min-height: 140px; + overflow: auto; + margin: 0 !important; + border-radius: 10px; + border: 1px solid #414868; + background: #0f1018; + -webkit-overflow-scrolling: touch; +} +body.editor-embed-admin #editor-status { + flex: 0 0 auto; + margin: 0.3rem 0 0; + font-size: 0.8rem; + line-height: 1.35; +} +body.editor-embed-admin .editor-legend-short { + margin: 0.25rem 0 0; + font-size: 0.76rem; + line-height: 1.4; + color: #a8b0d0; +} +body.editor-embed-admin .editor-toolbar { + margin-top: 0.25rem; + margin-bottom: 0.35rem; +} +body.editor-embed-admin .frogger-lanes { + margin: 0.35rem 0; +} +body.editor-embed-admin .editor-bg-section { + margin: 0.35rem 0 0.15rem; +} +h1 { color: #7aa2f7; } +a { color: #9ece6a; } +label { display: inline-block; margin: 0.25rem 0.5rem 0.25rem 0; } +input, button { padding: 0.4rem 0.6rem; margin: 0 0.25rem 0 0; border-radius: 6px; border: 1px solid #414868; } +input { background: #24283b; color: #c0caf5; } +button { background: #7aa2f7; color: #1a1b26; cursor: pointer; border: none; } +.legend { font-size: 0.9rem; color: #a9b1d6; margin: 0.5rem 0; } +.frogger-lanes { margin: 1rem 0; padding: 0.75rem; background: #24283b; border-radius: 8px; position: relative; z-index: 2; min-height: 80px; } +.frogger-lanes h3 { margin: 0 0 0.5rem 0; color: #7aa2f7; font-size: 1rem; } +.frogger-lanes table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.frogger-lanes th, .frogger-lanes td { padding: 0.25rem 0.5rem; text-align: left; border: 1px solid #414868; } +.frogger-lanes select, .frogger-lanes input[type="number"] { padding: 0.35rem 0.5rem; min-width: 4rem; background: #1a1b26; color: #c0caf5; border: 1px solid #414868; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } +.frogger-lanes select { min-width: 6rem; } +.frogger-lanes .lane-speed { width: 4rem; } +.frogger-lanes .lane-spacing { width: 4rem; } +.frogger-lanes td { vertical-align: middle; } +.frogger-lanes select:disabled, .frogger-lanes input:disabled { opacity: 0.6; cursor: not-allowed; } +.game-type-hint { font-size: 0.85rem; color: #a9b1d6; margin-left: 0.5rem; } +.editor-toolbar { margin: 1rem 0; flex-wrap: wrap; display: flex; align-items: center; gap: 0.5rem 1rem; } +.editor-toolbar label, .editor-toolbar .game-type-hint { margin: 0; } +.editor-bg-section { margin: 0.75rem 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } +.editor-bg-section label { margin: 0; } +.canvas-wrap { margin: 1rem 0; } +.container { padding-left: 1rem; padding-right: 1rem; } +#editor-canvas { background: #1a1b26; display: block; } +.game-wrap { display: flex; flex-direction: column; height: 100vh; height: 100dvh; min-height: 0; } +.game-header { flex-shrink: 0; background: #24283b; padding: 0.5rem 1rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; } +.game-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.game-header button { flex-shrink: 0; } +#game-canvas { + flex: 1 1 auto; + display: block; + width: 100%; + min-height: 0; + min-width: 0; + touch-action: none; +} +.game-wrap .chat-panel { flex-shrink: 0; } +.chat-panel { height: 160px; border-top: 1px solid #414868; display: flex; flex-direction: column; } +#chat-messages { flex: 1; overflow-y: auto; padding: 0.5rem; font-size: 0.9rem; } +#chat-form { display: flex; padding: 0.5rem; gap: 0.5rem; } +#chat-input { flex: 1; } + +.lobby-section { margin: 1.25rem 0; padding: 1rem; background: #24283b; border-radius: 8px; } +.lobby-section h2 { margin: 0 0 0.75rem 0; font-size: 1rem; color: #7aa2f7; } +#room-list { list-style: none; margin: 0; padding: 0; } +#room-list .room-item { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #414868; } +#room-list .room-item:last-child { border-bottom: none; } +#room-list .room-name { font-weight: 600; color: #c0caf5; min-width: 0; } +#room-list .room-meta { font-size: 0.85rem; color: #a9b1d6; } +#room-list .room-meta-inline { font-size: 0.8rem; color: #565f89; font-weight: normal; } +#room-list-status { margin: 0; color: #a9b1d6; font-size: 0.9rem; } + +.room-lobby-peers { margin: 1rem 0; padding: 0.75rem; background: #24283b; border-radius: 8px; } +.room-lobby-peer { padding: 0.35rem 0; color: #c0caf5; } +.room-lobby-peer:not(:last-child) { border-bottom: 1px solid #414868; } +#lobby-map-wrap { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; min-width: 100vw; min-height: 100vh; z-index: 0; } +body.room-lobby-page { + background: + radial-gradient(120% 120% at 50% -12%, rgba(86, 122, 255, 0.26) 0%, rgba(30, 37, 83, 0.16) 40%, rgba(7, 10, 25, 0) 75%), + linear-gradient(180deg, #1a1d46 0%, #111530 42%, #090d22 100%); + color: #ebf6ff; +} +#lobby-map-wrap::before { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: + radial-gradient(80% 65% at 50% 18%, rgba(106, 194, 255, 0.2), rgba(106, 194, 255, 0) 72%), + linear-gradient(180deg, rgba(10, 16, 46, 0.25) 0%, rgba(6, 12, 31, 0.05) 30%, rgba(5, 9, 24, 0.5) 100%); +} +#lobby-map-wrap::after { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + box-shadow: inset 0 0 160px rgba(8, 12, 30, 0.85); +} +#lobby-map-canvas { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + margin: 0; + padding: 0; + background: + radial-gradient(circle at 50% 20%, rgba(124, 197, 255, 0.25) 0%, rgba(124, 197, 255, 0) 45%), + linear-gradient(180deg, #252c66 0%, #1a2150 45%, #121a3b 100%); + box-sizing: border-box; +} +#lobby-map-canvas:focus { + outline: none; +} +#lobby-map-canvas:focus-visible { + outline: 2px solid rgba(122, 162, 247, 0.45); + outline-offset: -2px; +} +/* LobbyB (mn8nx46h) — ไม่แสดงปุ่มพร้อม */ +body.room-lobby--lobby-b .room-lobby-ready-fixed { + display: none !important; +} +.lobby-interact-hint { + position: absolute; + bottom: max(10px, env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 2; + margin: 0; + padding: 0.2rem 0.55rem; + font-size: clamp(0.72rem, 1.8vw, 0.85rem); + color: rgba(203, 213, 245, 0.9); + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55); + pointer-events: none; + font-family: system-ui, 'Kanit', sans-serif; + white-space: nowrap; +} +.lobby-interact-hint kbd { + display: inline-block; + padding: 0.06em 0.32em; + border-radius: 4px; + background: rgba(26, 27, 38, 0.88); + border: 1px solid rgba(122, 162, 247, 0.45); + font-size: 0.88em; + font-family: inherit; +} +.lobby-zoom-pct { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 2; + padding: 6px 12px; + border-radius: 8px; + background: rgba(26, 27, 38, 0.85); + color: #c0caf5; + font-size: 1rem; + font-weight: 600; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} +.lobby-zoom-pct.lobby-zoom-pct-visible { opacity: 1; } +/* ซ่อนแผง overlay รายชื่อ / host / แชท — โถงโฟกัสแผนที่ + ปุ่ม READY/ไมค์ ด้านนอก */ +.room-lobby-overlay { display: none !important; } +.room-lobby-top-left-cluster { + position: fixed; + top: max(12px, env(safe-area-inset-top)); + left: max(0px, env(safe-area-inset-left)); + z-index: 55; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + pointer-events: none; +} +.room-lobby-top-left-cluster .room-lobby-exit-fixed { + pointer-events: auto; +} +.room-lobby-room-id-pill { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 12px; + border: 1px solid rgba(106, 232, 255, 0.58); + background: linear-gradient(180deg, rgba(43, 67, 128, 0.86), rgba(22, 34, 74, 0.9)); + box-shadow: + inset 0 0 0 1px rgba(188, 247, 255, 0.2), + 0 0 18px rgba(80, 213, 255, 0.32); + color: #dff6ff; + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif; + letter-spacing: 0.03em; +} +.room-lobby-room-id-label { + font-weight: 600; + font-size: clamp(0.78rem, 1.7vw, 0.9rem); + opacity: 0.92; +} +#room-id-value { + font-weight: 800; + font-size: clamp(0.85rem, 1.9vw, 1rem); + color: #f4fdff; +} +.room-lobby-room-id-lock { + font-size: 0.85rem; + filter: drop-shadow(0 0 6px rgba(105, 227, 255, 0.6)); +} + +/* มุมขวาบน — โปรไฟล์ตัวละคร */ +.room-lobby-top-right-cluster { + position: fixed; + top: max(12px, env(safe-area-inset-top)); + right: max(12px, env(safe-area-inset-right)); + z-index: 56; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.4rem; + pointer-events: none; +} +.room-lobby-top-right-cluster > * { + pointer-events: auto; +} +.room-lobby-top-right-actions { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.45rem; +} +.room-lobby-top-right-action-btn { + width: 161px; + height: 176px; + min-width: 161px; + min-height: 176px; + max-width: 161px; + max-height: 176px; + aspect-ratio: 161 / 176; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + cursor: pointer; +} +.room-lobby-top-right-action-btn img { + width: 161px; + height: 176px; + min-width: 161px; + min-height: 176px; + max-width: 161px; + max-height: 176px; + aspect-ratio: 161 / 176; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} +.room-lobby-profile-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; +} +.room-lobby-profile-frame { + position: relative; + width: 159px; + height: 157px; + min-width: 159px; + min-height: 157px; + max-width: 159px; + max-height: 157px; + aspect-ratio: 159 / 157; + border-radius: 0; + border: none; + overflow: hidden; + background: url(../img/btn-profile-bg.png) center/100% 100% no-repeat; + box-shadow: none; +} +.room-lobby-profile-avatar-viewport { + position: absolute; + left: 12px; + top: 10px; + width: 124px; + height: 118px; + overflow: hidden; + border-radius: 12px; +} +.room-lobby-profile-frame #room-lobby-profile-avatar { + position: absolute; + left: 50%; + top: 5px; + display: block; + width: 120%; + height: auto; + max-width: none; + object-fit: contain; + object-position: center top; + transform: translateX(-50%); + transform-origin: center center; + image-rendering: pixelated; + image-rendering: crisp-edges; +} +.room-lobby-profile-set-btn { + position: absolute; + right: 0; + bottom: 0; + width: 71px; + height: 71px; + min-width: 71px; + min-height: 71px; + max-width: 71px; + max-height: 71px; + aspect-ratio: 71 / 71; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + cursor: pointer; + z-index: 2; +} +.room-lobby-profile-set-btn img { + display: block; + width: 71px; + height: 71px; + min-width: 71px; + min-height: 71px; + max-width: 71px; + max-height: 71px; + aspect-ratio: 71 / 71; + object-fit: contain; +} + +/* Profile overlay (open from btn-profile-set) */ +.room-lobby-profile-overlay { + position: fixed; + inset: 0; + z-index: 10060; + display: flex; + align-items: center; + justify-content: center; + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); +} +.room-lobby-profile-overlay.is-hidden { + display: none !important; +} +.room-lobby-profile-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); +} +.room-lobby-profile-dialog { + position: relative; + z-index: 1; + width: min( + 1701px, + calc(100vw - 24px), + calc((100dvh - 24px) * (1701 / 951)) + ); + height: auto; + aspect-ratio: 1701 / 951; + max-height: calc(100dvh - 24px); + padding: 0; + background: url(../img/03-6-Profile/profile-pop-bg.png) center/100% 100% no-repeat; + border: none; + border-radius: 0; + box-shadow: none; + color: #eaf8ff; + box-sizing: border-box; + --rp-scale: 1; + --rp-close-hit-size: calc(62px * var(--rp-scale)); + --rp-close-hit-top: 5.6%; + --rp-close-hit-right: 2.9%; +} +.room-lobby-profile-title { + display: none !important; +} +.room-lobby-profile-close { + position: absolute; + top: var(--rp-close-hit-top); + right: var(--rp-close-hit-right); + width: var(--rp-close-hit-size); + height: var(--rp-close-hit-size); + border: none; + border-radius: 0; + background: rgba(0, 255, 255, 0.5); + color: transparent; + font-size: 0; + line-height: 1; + cursor: pointer; + text-shadow: none; + z-index: 3; + -webkit-tap-highlight-color: transparent; + opacity: 0.5; +} +.room-lobby-profile-content { + position: absolute; + top: 17%; + right: 7%; + bottom: 11%; + left: 7%; + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.3fr); + gap: 0; + align-items: stretch; + height: auto; + padding: 0; + box-sizing: border-box; +} +.room-lobby-profile-left-pane, +.room-lobby-profile-right-pane { + border: 2px solid rgba(92, 233, 255, 0.65); + border-radius: 12px; + background: transparent; + padding: clamp(10px, 1.4vw, 16px); + box-sizing: border-box; +} +.room-lobby-profile-left-pane { + border: none; + border-radius: 0; + background: transparent; + padding: calc(3px * var(--rp-scale)) calc(4px * var(--rp-scale)) calc(5px * var(--rp-scale)); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) calc(58px * var(--rp-scale)); + justify-items: center; + align-items: stretch; + row-gap: calc(10px * var(--rp-scale)); +} +.room-lobby-profile-inner-frame { + width: min(100%, 98%); + max-width: 100%; + aspect-ratio: 621 / 626; + height: 100%; + max-height: 100%; + min-width: 0; + background: url(../img/03-6-Profile/profile-char-frame.png) center/100% 100% no-repeat; + --pf-scale: var(--rp-scale, 1); + font-size: calc(16px * var(--pf-scale)); + padding: calc(12px * var(--pf-scale)) calc(11px * var(--pf-scale)) calc(9px * var(--pf-scale)); + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + align-self: stretch; + overflow: hidden; +} +.room-lobby-profile-coins { + margin: calc(10px * var(--pf-scale)) 0 calc(10px * var(--pf-scale)); + display: flex; + align-items: center; + justify-content: center; + gap: calc(8px * var(--pf-scale)); + font-size: calc(30px * var(--pf-scale)); + font-weight: 700; + width: 100%; + text-align: center; + line-height: 1; +} +.room-lobby-profile-coins strong { + color: #fff; + font-size: calc(48px * var(--pf-scale)); + font-weight: 700; + line-height: 1; +} +.room-lobby-profile-coins-icon { + width: calc(30px * var(--pf-scale)); + height: calc(30px * var(--pf-scale)); +} +.room-lobby-profile-coins-label { + height: calc(62px * var(--pf-scale)); + width: auto; + object-fit: contain; + margin-top: calc(6px * var(--pf-scale)); +} +.room-lobby-profile-char-card { + border: none; + border-radius: 0; + width: 100%; + min-width: 0; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: calc(3px * var(--pf-scale)); + background: transparent; + padding: calc(4px * var(--pf-scale)) 0 calc(6px * var(--pf-scale)); + box-sizing: border-box; + overflow: hidden; +} +.room-lobby-profile-char-card #room-lobby-profile-overlay-avatar { + width: 100%; + max-width: 100%; + height: auto; + object-fit: contain; + max-height: 83%; + margin-top: calc(-28px * var(--pf-scale)); + margin-bottom: calc(-9px * var(--pf-scale)); +} +.room-lobby-profile-overlay-name { + margin-top: 0; + font-size: calc(38px * var(--pf-scale)); + font-weight: 700; + color: #fff; + line-height: 1; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.4); + width: 100%; + max-width: 100%; + min-width: 0; + flex: 1 1 auto; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.room-lobby-profile-overlay-name-row { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; + gap: calc(6px * var(--pf-scale)); + width: 100%; + min-width: 0; + padding-left: calc(92px * var(--pf-scale)); + padding-right: calc(92px * var(--pf-scale)); + box-sizing: border-box; + margin-top: calc(-6px * var(--pf-scale)); + flex-shrink: 0; + min-height: calc(70px * var(--pf-scale)); + overflow: visible; +} +.room-lobby-profile-edit-btn { + padding: 0; + margin: 0; + position: absolute; + right: 4%; + top: 50%; + transform: translateY(-50%); + width: calc(88px * var(--pf-scale)); + height: calc(88px * var(--pf-scale)); + aspect-ratio: 1 / 1; + border: none; + border-radius: 0; + background: transparent; + cursor: pointer; +} +.room-lobby-profile-edit-btn img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-profile-name-sep { + margin-top: calc(-10px * var(--pf-scale)); + width: min(100%, 505px); + height: auto; + object-fit: contain; +} +.room-lobby-profile-overlay-agent { + margin-top: calc(4px * var(--pf-scale)); + font-size: calc(18px * var(--pf-scale)); + color: #F856E2; + font-weight: 500; + line-height: 1; + margin-bottom: calc(4px * var(--pf-scale)); + flex-shrink: 0; +} +.room-lobby-profile-logout { + margin-top: 0; + width: min(50%, calc(256px * var(--rp-scale))); + max-width: calc(256px * var(--rp-scale)); + aspect-ratio: 256 / 94; + height: auto; + border-radius: 0; + border: none; + background: transparent; + color: inherit; + font-size: 0; + font-weight: 400; + cursor: pointer; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; +} +.room-lobby-profile-logout img { + width: 100%; + height: 100%; + object-fit: fill; + display: block; +} +.room-lobby-profile-right-pane { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: calc(14px * var(--rp-scale)); + padding-top: calc(6px * var(--rp-scale)); + margin-left: calc(-32px * var(--rp-scale)); + min-width: 0; + border: none; + border-radius: 0; + background: transparent; + padding: 0; +} +.room-lobby-profile-archiv-groups { + display: flex; + align-items: flex-end; + justify-content: flex-start; + padding-left: calc(30px * var(--rp-scale)); + height: calc(60px * var(--rp-scale)); + overflow: visible; +} +.room-lobby-profile-archiv-group-btn { + width: calc(190px * var(--rp-scale)); + height: calc(60px * var(--rp-scale)); + margin: 0; + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} +.room-lobby-profile-archiv-group-btn + .room-lobby-profile-archiv-group-btn { + margin-left: calc(-14px * var(--rp-scale)); +} +.room-lobby-profile-archiv-group-btn img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-frame { + position: relative; + width: min(100%, calc(872px * var(--rp-scale))); + aspect-ratio: 872 / 515; + max-height: 100%; + background: url(../img/03-6-Profile/archiv-frame.png) center/100% 100% no-repeat; + padding: 4.8% 6.5% 5.8% 4.2%; + box-sizing: border-box; +} +.room-lobby-profile-achievements-list { + list-style: none; + margin: 0; + padding: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: calc(12px * var(--rp-scale)); + overflow: hidden; +} +.room-lobby-profile-archiv-item { + display: flex; + justify-content: flex-start; + align-items: center; + gap: calc(14px * var(--rp-scale)); + min-height: 0; +} +.room-lobby-profile-archiv-icon-bg { + width: calc(131px * var(--rp-scale)); + height: calc(123px * var(--rp-scale)); + aspect-ratio: 131 / 123; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: calc(6px * var(--rp-scale)); +} +.room-lobby-profile-archiv-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(8px, 0.8vw, 12px); +} +.room-lobby-profile-archiv-name { + width: calc(421px * var(--rp-scale)); + height: calc(72px * var(--rp-scale)); + max-width: 100%; + aspect-ratio: 421 / 72; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-count { + color: #ffe95a; + font-size: calc(38px * var(--rp-scale)); + line-height: 1; + font-weight: 700; + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif; +} +.room-lobby-profile-archiv-progress-wrap { + position: relative; + width: calc(571px * var(--rp-scale)); + max-width: 100%; + aspect-ratio: 571 / 56; + height: calc(56px * var(--rp-scale)); +} +.room-lobby-profile-archiv-progress-bg, +.room-lobby-profile-archiv-progress { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-progress { + clip-path: inset(0 calc(100% - var(--archiv-progress, 0%)) 0 0); +} +.room-lobby-profile-archiv-line { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-scroll { + position: absolute; + top: 13%; + right: 1.6%; + bottom: 7%; + width: calc(15px * var(--rp-scale)); + display: flex; + justify-content: center; +} +.room-lobby-profile-archiv-scroll-bg, +.room-lobby-profile-archiv-scroll-bar { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 100%; + object-fit: contain; + display: block; +} +.room-lobby-profile-archiv-scroll-bg { + top: 0; + bottom: 0; + height: 100%; +} +.room-lobby-profile-archiv-scroll-bar { + top: 8%; + height: 42%; +} +.room-lobby-profile-settings { + position: relative; + width: min(100%, calc(824px * var(--rp-scale))); + aspect-ratio: 824 / 169; + margin-top: auto; +} +.room-lobby-profile-settings-frame { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-profile-settings-row { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 calc(70px * var(--rp-scale)) 0 calc(90px * var(--rp-scale)); + box-sizing: border-box; +} +.room-lobby-profile-setting-item { + display: inline-flex; + align-items: center; + gap: calc(14px * var(--rp-scale)); +} +.room-lobby-profile-setting-label { + width: calc(99px * var(--rp-scale)); + height: calc(39px * var(--rp-scale)); + aspect-ratio: 99 / 39; + object-fit: contain; + display: block; +} +.room-lobby-profile-toggle { + margin: 0; + width: calc(152px * var(--rp-scale)); + height: calc(94px * var(--rp-scale)); + aspect-ratio: 152 / 94; + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} +.room-lobby-profile-toggle img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +/* Host Console popup */ +.room-lobby-host-console-overlay { + position: fixed; + inset: 0; + z-index: 10070; + display: flex; + align-items: center; + justify-content: center; + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); +} +.room-lobby-host-console-overlay.is-hidden { + display: none !important; +} +.room-lobby-host-console-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); +} +.room-lobby-host-console-dialog { + position: relative; + z-index: 1; + width: min(990px, calc(100vw - 24px), calc((100dvh - 24px) * (990 / 881))); + aspect-ratio: 990 / 881; + max-height: calc(100dvh - 24px); + --hc-scale: 1; + --hc-close-hit-size: calc(82px * var(--hc-scale)); + --hc-close-hit-top: 9%; + --hc-close-hit-right: 6.5%; + background: url(../img/03-4-Host-Console/host-console-bg.png) center/100% 100% no-repeat; + box-sizing: border-box; +} +.room-lobby-host-console-body { + position: absolute; + top: 13.6%; + right: 7.9%; + bottom: 6%; + left: 8.2%; + display: flex; + flex-direction: column; + align-items: center; + min-height: 0; + padding-top: calc(50px * var(--hc-scale)); + padding-bottom: calc(62px * var(--hc-scale)); + box-sizing: border-box; +} +.room-lobby-host-console-close { + position: absolute; + top: var(--hc-close-hit-top); + right: var(--hc-close-hit-right); + width: var(--hc-close-hit-size); + height: var(--hc-close-hit-size); + border: none; + border-radius: 0; + background: transparent; + color: transparent; + font-size: 0; + line-height: 1; + cursor: pointer; + text-shadow: none; + z-index: 3; + -webkit-tap-highlight-color: transparent; +} +.room-lobby-host-console-txt-img { + width: auto; + object-fit: contain; + display: block; +} +.room-lobby-host-console-txt-max { + height: calc(66px * var(--hc-scale)); + margin-bottom: calc(10px * var(--hc-scale)); +} +.room-lobby-host-console-max-wrap { + position: relative; + width: min(82%, calc(657px * var(--hc-scale))); + aspect-ratio: 657 / 141; + margin-bottom: calc(-10px * var(--hc-scale)); + margin-top: calc(-30px * var(--hc-scale)); +} +.room-lobby-host-console-textfield-bg { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-host-console-arrow-btn { + position: absolute; + top: 50%; + width: calc(51px * var(--hc-scale)); + height: calc(54px * var(--hc-scale)); + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + cursor: pointer; + transform: translateY(-50%); +} +.room-lobby-host-console-arrow-left { left: 4.9%; } +.room-lobby-host-console-arrow-right { right: 4.9%; } +.room-lobby-host-console-arrow-btn img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-host-console-max-value { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + min-width: 2ch; + text-align: center; + font-size: calc(40px * var(--hc-scale)); + font-weight: 700; + color: #eaf9ff; + text-shadow: 0 0 10px rgba(0, 255, 255, 0.35); +} +.room-lobby-host-console-summary { + margin: 0 0 calc(12px * var(--hc-scale)); + font-size: calc(24px * var(--hc-scale)); + color: #d9f3ff; + margin-bottom: 4%; +} +.room-lobby-host-console-line { + width: min(94%, calc(744px * var(--hc-scale))); + height: auto; + margin-bottom: calc(10px * var(--hc-scale)); + object-fit: contain; +} +.room-lobby-host-console-txt-members { + height: calc(61px * var(--hc-scale)); + margin-bottom: calc(8px * var(--hc-scale)); +} +.room-lobby-host-console-members { + width: min(84%, calc(694px * var(--hc-scale))); + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: scroll; + padding-right: calc(40px * var(--hc-scale)); + padding-left: calc(35px * var(--hc-scale)); + scrollbar-width: auto; + scrollbar-color: rgba(255, 104, 228, 0.98) rgba(20, 40, 70, 0.55); + scrollbar-gutter: stable both-edges; +} +.room-lobby-host-console-members::-webkit-scrollbar { + width: clamp(24px, calc(36px * var(--hc-scale)), 42px); +} +.room-lobby-host-console-members::-webkit-scrollbar-track { + background: rgba(20, 40, 70, 0.55); + border-radius: 999px; + border: 1px solid rgba(90, 220, 255, 0.35); +} +.room-lobby-host-console-members::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(255, 125, 236, 1) 0%, rgba(241, 74, 215, 1) 100%); + border-radius: 999px; + border: 3px solid rgba(16, 32, 56, 0.65); + background-clip: padding-box; + box-shadow: 0 0 10px rgba(255, 84, 226, 0.45); +} +.room-lobby-host-console-members-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: calc(18px * var(--hc-scale)); +} +.room-lobby-host-console-member-item { + position: relative; + min-height: calc(44px * var(--hc-scale)); + padding: calc(2px * var(--hc-scale)) calc(104px * var(--hc-scale)) calc(2px * var(--hc-scale)) calc(12px * var(--hc-scale)); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.6rem; + color: #e9f8ff; + font-size: calc(28px * var(--hc-scale)); + background: url(../img/03-4-Host-Console/host-console-line-2.png) left bottom/100% 3px no-repeat; +} +.room-lobby-host-console-member-item.is-host { + min-height: calc(80px * var(--hc-scale)); + padding-right: calc(12px * var(--hc-scale)); + background: url(../img/03-4-Host-Console/host-console-hostname.png) center/100% 100% no-repeat; + font-weight: 700; +} +.room-lobby-host-console-member-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; + padding-left: 4%; + margin-top: -4%; +} +.room-lobby-host-console-member-item.is-host .room-lobby-host-console-member-name { + margin-top: 0%; /* เพิ่มค่านี้ = ขยับลง */ +} +.room-lobby-host-console-delete-btn { + position: absolute; + right: calc(10px * var(--hc-scale)); + top: 50%; + transform: translateY(-50%); + width: calc(84px * var(--hc-scale)); + height: calc(84px * var(--hc-scale)); + min-width: calc(84px * var(--hc-scale)); + padding: 0; + border: none; + border-radius: 0; + background: transparent; + cursor: pointer; + margin-top: -2%; + margin-right: -1%; +} +.room-lobby-host-console-delete-btn img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.room-lobby-host-console-delete-btn:disabled { + opacity: 0.35; + cursor: default; +} +.room-lobby-host-console-confirm { + position: absolute; + left: 50%; + bottom: calc(-60px * var(--hc-scale)); + transform: translateX(-50%); + width: min(43%, calc(350px * var(--hc-scale))); + aspect-ratio: 350 / 129; + border: none; + border-radius: 0; + padding: 0; + background: transparent; + cursor: pointer; +} +.room-lobby-host-console-confirm img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +/* Profile popup: tightened scaling for reduced viewports */ +@media (max-width: 1100px), (max-height: 700px) { + .room-lobby-profile-content { + top: 13%; + right: 4%; + bottom: 8%; + left: 4%; + gap: clamp(8px, 1vw, 14px); + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.12fr); + } + .room-lobby-profile-left-pane { + grid-template-rows: minmax(0, 1fr) auto; + row-gap: clamp(3px, 0.5vw, 6px); + } + .room-lobby-profile-logout { + width: min(58%, 100%); + } + .room-lobby-profile-char-card #room-lobby-profile-overlay-avatar { + width: 74%; + max-width: 74%; + max-height: 60%; + } +} +@media (max-width: 900px), (max-height: 560px) { + .room-lobby-profile-content { + top: 11%; + right: 3%; + bottom: 7%; + left: 3%; + gap: 8px; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.08fr); + } + .room-lobby-profile-logout { + width: min(62%, 100%); + } + .room-lobby-profile-char-card #room-lobby-profile-overlay-avatar { + width: 64%; + max-width: 64%; + max-height: 56%; + } +} +.room-lobby-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; } +.room-lobby-actions .room-lobby-btn-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; opacity: 0; pointer-events: none; } +.room-lobby-actions label { margin: 0; } +.room-lobby-actions #btn-start { background: #9ece6a; color: #1a1b26; } +#host-only { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; } +#host-only select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid #414868; background: #24283b; color: #c0caf5; } +.host-start-area-hint { + flex-basis: 100%; + margin: 0; + font-size: 0.88rem; + color: #e0af68; + line-height: 1.35; + max-width: 100%; +} +#host-only #btn-start:disabled { + opacity: 0.5; + cursor: not-allowed; + filter: grayscale(0.25); +} +.room-lobby-chat { margin-top: 1rem; } +.room-lobby-chat.chat-panel { + border-radius: 12px; + overflow: hidden; + box-shadow: none; +} +.room-lobby-chat-peers.chat-panel { + position: fixed; + right: 0.25rem; + bottom: 10rem; + width: 576px; + height: 525px; + max-width: calc(100vw - 2.5rem); + z-index: 52; + margin-top: 0; + display: flex; + flex-flow: column; + padding: 2rem; + box-sizing: border-box; + border-top: 0px solid #414868; + background: url(../img/chat-msg-bg-alpha.png) center/100% 100% no-repeat; + background-color: transparent; +} +.room-lobby-chat-peers.chat-panel .chat-header-title.chat-header-title-img img { width: 90px; height: 77px; } +.room-lobby-chat-peers.chat-panel .chat-close-btn { width: 48px; height: 48px; } +.room-lobby-chat-peers.chat-panel .chat-close-btn img { width: 50px; height: 50px; } +.room-lobby-chat-peers.chat-panel #chat-messages { padding: 0.75rem; font-size: 1.05rem; gap: 0.5rem; } +.room-lobby-chat-peers.chat-panel .chat-form-bar { padding: 0.6rem 0; gap: 0.5rem; } +.room-lobby-chat-peers.chat-panel #chat-input { padding: 0.6rem 0.85rem; font-size: 1.05rem; } +.room-lobby-chat-peers.chat-panel .chat-send-btn { width: 52px; height: 52px; } +.room-lobby-chat-peers.chat-panel .chat-send-btn img { width: 58px; height: 58px; } +.room-lobby-chat-peers.chat-panel .chat-msg { padding: 0.6rem 1rem; font-size: 1.05rem; } +.room-lobby-chat-peers.chat-panel .chat-header-bar { + margin: 0 -2rem 0 -2rem; + padding-left: 2rem; + padding-right: 2rem; + justify-content: space-between; + align-items: center; + min-height: 72px; + height: 72px; +} +.room-lobby-chat-peers.chat-panel #chat-messages { + flex: 1; + min-height: 0; + height: auto; +} +.room-lobby-chat .chat-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0; + min-height: 48px; + height: 48px; + background: url(../img/chat-line.png) bottom repeat-x; + background-size: 100% 4px; + background-color: transparent; +} +.room-lobby-chat-peers .chat-header-title.chat-header-title-img { + display: inline-flex; + align-items: center; + line-height: 0; +} +.room-lobby-chat-peers .chat-header-title.chat-header-title-img img { + width: 60px; + height: 51px; + object-fit: contain; + display: block; +} +.room-lobby-chat-ai .chat-header-title.chat-header-title-img { + display: flex; + align-items: flex-end; + gap: 0.5rem; + line-height: 0; + min-width: 0; + overflow: visible; +} +.room-lobby-chat-ai .chat-header-title.chat-header-title-img .ai-chat-title-label { + height: 64px; + width: auto; + max-width: none; + object-fit: contain; + display: block; + flex-shrink: 0; + margin-bottom: -2px; +} +/* รูปหัวแชท AI — ยกขึ้นเหนือเส้น */ +.room-lobby-chat-ai .chat-header-title.chat-header-title-img img { + width: 86px; + height: 92px; + object-fit: cover; + display: block; + flex-shrink: 0; + margin-top: -27px; + margin-left: -6px; + position: relative; + z-index: 1; +} +.room-lobby-chat .chat-header-title { + font-size: 1rem; + font-weight: 600; + color: #a0aec0; +} +.room-lobby-chat .chat-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + margin-left: auto; +} +.room-lobby-chat-peers .chat-header-bar .chat-close-btn { margin-left: 0; } +.room-lobby-chat .chat-close-btn img { width: 34px; height: 34px; object-fit: contain; } +.room-lobby-chat .chat-close-btn:hover { opacity: 0.9; } +.room-lobby-chat.chat-panel-collapsed #chat-messages, +.room-lobby-chat.chat-panel-collapsed .chat-form-bar { display: none !important; } +.room-lobby-chat-peers.chat-panel-collapsed .chat-header-title { display: none !important; } +.room-lobby-chat-peers.chat-panel-collapsed .chat-header-bar { + justify-content: center; + padding: 0; + background: none !important; + border: none; +} +.room-lobby-chat-peers.chat-panel-collapsed.chat-panel { + width: 80px; + height: 80px; + min-height: 0; + padding: 0; + right: 1.25rem; + bottom: 6.25rem; + display: flex; + align-items: center; + justify-content: center; + background: none !important; + background-color: transparent !important; + border: none; + box-shadow: none; +} +.room-lobby-chat-peers.chat-panel-collapsed .chat-close-btn { + width: 80px; + height: 80px; + overflow: visible; +} +.room-lobby-chat-peers.chat-panel-collapsed .chat-close-btn img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; +} +.room-lobby-chat #chat-messages, +.room-lobby-chat #ai-chat-messages { + height: 100px; + overflow-y: auto; + padding: 0.5rem; + font-size: 0.85rem; + border: none; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + background: none; + background-color: transparent; + box-shadow: none; +} +.room-lobby-chat .chat-messages-bg { border-radius: 0; } +.room-lobby-chat #chat-form.chat-form-bar, +.room-lobby-chat #ai-chat-form.chat-form-bar { + display: flex; + gap: 0.4rem; + align-items: center; + padding: 0.4rem 0; + background: none; + background-color: transparent; + border-top: none; + flex-shrink: 0; +} +.room-lobby-chat-peers .chat-form-bar { + padding-left: 0; + padding-right: 0; +} +.room-lobby-chat #chat-input, +.room-lobby-chat #ai-chat-input { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + border-radius: 10px; + border: 1px solid rgba(0, 212, 255, 0.25); + background: rgba(30, 35, 50, 0.8); + color: #c0caf5; + font-size: 0.9rem; + box-sizing: border-box; + margin-left: 9px; +} +.room-lobby-chat .chat-send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; +} +.room-lobby-chat .chat-send-btn img { width: 45px; height: 45px; object-fit: contain; } +.room-lobby-chat .chat-send-btn:hover { opacity: 0.9; } +.room-lobby-chat .chat-msg { + padding: 0.4rem 0.75rem; + border-radius: 12px; + max-width: 85%; + background-size: 100% 100%; + background-repeat: no-repeat; + background-position: center; +} +.room-lobby-chat .chat-msg-mine { + align-self: flex-end; + text-align: right; + background-image: url(../img/chat-bubble-me.png); + background-color: rgba(122, 162, 247, 0.9); + color: #1a1b26; +} +.room-lobby-chat .chat-msg-other { + align-self: flex-start; + text-align: left; + background-image: url(../img/chat-bubble-friend.png); + background-color: rgba(65, 72, 104, 0.9); + color: #c0caf5; +} +.room-lobby-chat .chat-msg-ai { + align-self: flex-start; + text-align: left; + background-image: url(../img/chat-bubble-friend.png); + background-color: rgba(158, 206, 106, 0.9); + color: #1a1b26; +} +.room-lobby-chat .chat-msg-system { + align-self: center; + text-align: center; + max-width: 95%; + background-image: none; + background-color: rgba(26, 27, 38, 0.72); + color: #9aa5ce; + font-size: 0.88rem; + font-style: italic; + padding: 0.35rem 0.6rem; +} + +/* AI chat — loading แบบ Messenger (จุดกระโดด 3 จุด), อยู่ติดข้อความล่าสุดในลิสต์ */ +.room-lobby-chat .ai-chat-typing { + display: flex; + align-items: center; + gap: 4px; + padding: 12px 14px; + min-height: 24px; + flex-shrink: 0; + background-image: url(../img/chat-bubble-friend.png); + background-color: rgba(158, 206, 106, 0.9); + background-size: 100% 100%; + background-repeat: no-repeat; + background-position: center; +} +.room-lobby-chat .ai-typing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #1a1b26; + animation: ai-typing-bounce 1.4s ease-in-out infinite both; +} +.room-lobby-chat .ai-typing-dot:nth-child(1) { animation-delay: 0s; } +.room-lobby-chat .ai-typing-dot:nth-child(2) { animation-delay: 0.2s; } +.room-lobby-chat .ai-typing-dot:nth-child(3) { animation-delay: 0.4s; } +@keyframes ai-typing-bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-6px); } +} + +/* AI chat panel — match Main-Lobby lobby-ai-chat-panel */ +.room-lobby-chat-ai.chat-panel { + position: fixed; + left: 8px; + right: auto; + bottom: clamp(0.5rem, 2vw, 1rem); + width: 454px; + height: 526px; + max-width: calc(100vw - 2.5rem); + z-index: 51; + margin: 0; + display: flex; + flex-flow: column; + box-sizing: border-box; + padding: 2.6rem; + overflow: visible; + background: url(../img/chat-msg-bg-alpha.png) center/100% 100% no-repeat; + background-color: transparent; + border: none; + box-shadow: none; + border-radius: 12px; + pointer-events: none; +} +.room-lobby-chat-ai.chat-panel .chat-header-bar, +.room-lobby-chat-ai.chat-panel #ai-chat-messages, +.room-lobby-chat-ai.chat-panel #ai-chat-form, +.room-lobby-chat-ai.chat-panel .ai-chat-admin-link { + pointer-events: auto; +} +.room-lobby-chat-ai.chat-panel .chat-header-bar { + margin: 0 -2rem 0 -2rem; + padding: 0 1rem; + justify-content: space-between; + align-items: center; + min-height: 50px; + height: 50px; + overflow: visible; + background: url(../img/AI-chat-line.png) bottom repeat-x; + background-size: 100% 3px; + background-color: transparent; +} +.room-lobby-chat-ai.chat-panel .chat-header-title.chat-header-title-img img { + width: 100px; + height: 116px; + margin-top: -39px; + margin-left: 16px; + object-fit: cover; + position: relative; + z-index: 1; +} +.room-lobby-chat-ai.chat-panel .chat-header-title.chat-header-title-img .ai-chat-title-label { + height: 80px; + width: auto; + max-width: none; + margin-bottom: -4px; + margin-left: -5px; + transform: translateX(-6px); +} +.room-lobby-chat-ai.chat-panel .chat-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + margin-left: auto; +} +.room-lobby-chat-ai.chat-panel .chat-close-btn img { + width: 60px; + height: 60px; + object-fit: contain; +} +.room-lobby-chat-ai.chat-panel .chat-close-btn:hover { + opacity: 0.9; +} +.room-lobby-chat-ai.chat-panel #ai-chat-messages { + flex: 1; + min-height: 0; + height: auto; + overflow-y: auto; + padding: 0.2rem 0.35rem 0.25rem 0; + font-size: 1.05rem; + gap: 0.5rem; + background: none; + border: none; + scrollbar-width: thin; + scrollbar-color: rgba(244, 252, 255, 0.98) rgba(53, 110, 167, 0.28); +} +.room-lobby-chat-ai.chat-panel #ai-chat-messages::-webkit-scrollbar { + width: 1rem; + scrollbar-width: auto; +} +.room-lobby-chat-ai.chat-panel #ai-chat-messages::-webkit-scrollbar-track { + border-radius: 999px; + background: linear-gradient(180deg, rgba(38, 77, 125, 0.45) 0%, rgba(32, 66, 106, 0.38) 100%); + box-shadow: inset 0 0 0 1px rgba(107, 193, 255, 0.25); +} +.room-lobby-chat-ai.chat-panel #ai-chat-messages::-webkit-scrollbar-thumb { + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(221, 244, 255, 0.96) 100%); + box-shadow: + 0 0 10px rgba(169, 239, 255, 0.55), + inset 0 0 0 1px rgba(180, 235, 255, 0.9); +} +.room-lobby-chat-ai.chat-panel .chat-form-bar { + display: flex; + gap: 0; + align-items: center; + padding: 0.6rem 0; + flex-shrink: 0; + background: none; + border: none; + width: calc(100% + 3rem); + margin-left: 0; + margin-right: 0; +} +.room-lobby-chat-ai.chat-panel #ai-chat-input { + flex: 0.85; + min-width: 0; + min-height: 50px; + padding: 0.6rem 0.85rem; + border-radius: 8px; + border: 1px solid rgba(0, 212, 255, 0.32); + background: rgba(30, 35, 50, 0.55); + color: #c0caf5; + font-size: 1.05rem; + margin: 0; +} +.room-lobby-chat-ai.chat-panel .chat-send-btn { + width: 73px; + height: 73px; + margin-left: 0; + margin-right: -0.7rem; +} +.room-lobby-chat-ai.chat-panel .chat-send-btn img { + width: 102px; + height: 98px; + object-fit: contain; +} +.room-lobby-chat-ai.chat-panel .chat-send-btn:focus, +.room-lobby-chat-ai.chat-panel .chat-send-btn:focus-visible { + outline: none !important; + box-shadow: none !important; + border-color: transparent !important; +} +.room-lobby-chat-ai.chat-panel .chat-msg { + padding: 0.58rem 0.92rem; + font-size: 1.05rem; + line-height: 1.33; + border: none; + box-shadow: none; +} +.room-lobby-chat-ai.chat-panel .chat-msg-mine { + background-color: rgba(122, 162, 247, 0.2); + color: #f787e4; +} +.room-lobby-chat-ai.chat-panel .chat-msg-ai { + background-color: rgba(158, 206, 106, 0.2); + color: #f2f8ff; +} +.room-lobby-chat-ai.chat-panel .ai-chat-typing { + padding: 14px 18px; + min-height: 28px; + background-color: rgba(158, 206, 106, 0.32); +} +.room-lobby-chat-ai.chat-panel .ai-typing-dot { + width: 8px; + height: 8px; + background: #f2f8ff; +} +.room-lobby-chat-ai.chat-panel .ai-chat-admin-link { + margin: 0.35rem 0 0 0; + font-size: 0.75rem; + flex-shrink: 0; +} +.room-lobby-chat-ai.chat-panel, +.room-lobby-chat-ai.chat-panel #ai-chat-messages, +.room-lobby-chat-ai.chat-panel #ai-chat-input, +.room-lobby-chat-ai.chat-panel .chat-msg, +.room-lobby-chat-ai.chat-panel .ai-chat-typing, +.room-lobby-chat-ai.chat-panel .chat-send-btn, +.room-lobby-chat-ai.chat-panel .ai-chat-admin-link, +.room-lobby-chat-ai.chat-panel .ai-chat-admin-link a { + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; +} +.room-lobby-chat-ai.chat-panel #ai-chat-messages, +.room-lobby-chat-ai.chat-panel #ai-chat-messages * { + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; +} +.room-lobby-chat-ai.chat-panel #ai-chat-input::placeholder { + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; +} + +/* Host console: force NotoSansThai for highlighted text areas */ +.room-lobby-host-console-summary, +.room-lobby-host-console-member-item, +.room-lobby-host-console-member-name, +.room-lobby-host-console-max-value { + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; +} + +/* AI chat collapsed — match Main-Lobby lobby-btn-ai-chat style */ +.room-lobby-chat-ai.chat-panel-collapsed #ai-chat-messages, +.room-lobby-chat-ai.chat-panel-collapsed .chat-form-bar, +.room-lobby-chat-ai.chat-panel-collapsed .ai-chat-admin-link { display: none !important; } +.room-lobby-chat-ai.chat-panel-collapsed .chat-header-title { display: none !important; } +.room-lobby-chat-ai.chat-panel-collapsed .chat-header-bar { + justify-content: center; + padding: 0; + background: none !important; + border: none; + min-height: 0; + height: auto; +} +.room-lobby-chat-ai.chat-panel-collapsed.chat-panel { + width: 213px; + max-width: 213px; + height: 201px; + min-height: 201px; + padding: 0; + left: max(1.25rem, env(safe-area-inset-left)); + bottom: clamp(0.5rem, 2vw, 1rem); + display: flex; + align-items: center; + justify-content: center; + background: none !important; + background-color: transparent !important; + border: none; + box-shadow: none; +} +.room-lobby-chat-ai.chat-panel-collapsed .chat-close-btn { + width: 213px; + height: 201px; + min-width: 213px; + min-height: 201px; + max-width: 213px; + max-height: 201px; + aspect-ratio: 213 / 201; + overflow: visible; + border-radius: 0; + margin-left: 0; +} +.room-lobby-chat-ai.chat-panel-collapsed .chat-close-btn img { + width: 213px; + height: 201px; + min-width: 213px; + min-height: 201px; + max-width: 213px; + max-height: 201px; + aspect-ratio: 213 / 201; + object-fit: contain; + object-position: center; + display: block; +} + +.room-lobby-exit-fixed { + position: static; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: max-content; + max-width: calc(100vw - 24px); + box-sizing: border-box; +} +.room-lobby-exit-fixed .btn-exit-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin: 0; + width: clamp(58px, 9vw, 72px); + height: clamp(58px, 9vw, 72px); + min-width: clamp(58px, 9vw, 72px); + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + cursor: pointer; +} +.room-lobby-exit-fixed .btn-exit-icon:hover { + transform: translateY(-1px); + filter: brightness(1.08); +} +.room-lobby-exit-fixed .btn-exit-icon img { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.room-lobby-ready-fixed { + position: fixed; + bottom: max(2rem, env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 120; + pointer-events: none; +} +.room-lobby-ready-fixed .ready-label, +.room-lobby-ready-fixed .ready-check-input { + pointer-events: auto; +} +.room-lobby-ready-fixed .ready-label { + position: relative; + display: inline-block; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + cursor: pointer; + transition: transform 0.15s ease, filter 0.15s ease; + line-height: 0; + filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.42)); +} +.room-lobby-ready-fixed .ready-label:hover { + transform: scale(1.04); + filter: brightness(1.08); +} +.room-lobby-ready-fixed .ready-label:active { + transform: scale(0.98); +} +.room-lobby-ready-fixed .ready-check-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + z-index: 2; +} +.room-lobby-ready-fixed .ready-label-img { + display: block; + height: clamp(44px, 8vh, 72px); + width: auto; + max-width: min(240px, 78vw); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} +.room-lobby-ready-fixed .ready-label--active .ready-label-img { + filter: drop-shadow(0 0 10px rgba(158, 206, 106, 0.45)); +} +body.room-lobby-page #room-players-hud { + top: max(10px, env(safe-area-inset-top)) !important; + min-width: 0; + border-radius: 0; + border: none; + background: transparent; + box-shadow: none; + backdrop-filter: none; + color: #e3f6ff !important; + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif !important; + text-transform: none; + letter-spacing: 0.08em; + text-shadow: + 0 2px 0 rgba(0, 0, 0, 0.8), + 0 0 12px rgba(0, 0, 0, 0.55); + text-align: center; +} + +/* มุมขวาล่าง — ไมค์ทุกห้อง + ปุ่ม LobbyB (แว่น/ถ้วย) */ +.room-lobby-br-fixed { + --lobby-br-btn: 80px; + position: fixed; + bottom: max(0.95rem, env(safe-area-inset-bottom)); + right: max(1.25rem, env(safe-area-inset-right)); + z-index: 400; + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 10px; + pointer-events: none; +} +.room-lobby-br-fixed .lobby-b-extra-row, +.room-lobby-br-fixed .btn-voice-icon { + pointer-events: auto; +} +.lobby-b-extra-row { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 10px; +} +.lobby-b-extra-row.is-hidden { + display: none !important; +} +/* โหมดเกม (ควิซ) ใน room-lobby — ไม่โชว์แว่น/ถ้วย LobbyB */ +body.room-lobby--quiz-active .lobby-b-extra-row { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; +} +/* ปุ่มมุมขวาล่าง — ขนาดเดียวกัน (กัน UA border / box model ต่างกัน) */ +.room-lobby-br-fixed .lobby-b-sq-btn, +.room-lobby-br-fixed .btn-voice-icon { + box-sizing: border-box; + width: var(--lobby-br-btn); + height: var(--lobby-br-btn); + min-width: var(--lobby-br-btn); + min-height: var(--lobby-br-btn); + max-width: var(--lobby-br-btn); + max-height: var(--lobby-br-btn); + margin: 0; + padding: 0; + border: none; + border-radius: 0; + appearance: none; + -webkit-appearance: none; + flex-shrink: 0; + cursor: pointer; + background: transparent; + box-shadow: none; +} +/* สไตล์เดียวกับปุ่มไมค์ — ไม่มีกรอบ cyan / bg-item */ +.lobby-b-sq-btn { + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, filter 0.15s ease; +} +.lobby-b-sq-btn:hover { + transform: scale(1.04); + filter: brightness(1.08); +} +.lobby-b-sq-btn:active { transform: scale(0.98); } +.room-lobby-br-fixed .lobby-b-sq-btn img, +.room-lobby-br-fixed .btn-voice-icon img { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center; + display: block; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} +.room-lobby-br-fixed .btn-voice-icon { + display: flex; + align-items: center; + justify-content: center; + background: transparent; +} + +/* Desktop tuning: keep controls visibly large and readable */ +@media (min-width: 1024px) { + .room-lobby-chat-peers.chat-panel { + right: 0.9rem; + bottom: 11rem; + width: 454px; + height: 414px; + padding: 1.7rem 1.45rem 1.15rem; + border-radius: 0; + background: url(../img/chat-bg.png) center/100% 100% no-repeat; + border: none; + box-shadow: none; + backdrop-filter: none; + } + .room-lobby-chat-peers.chat-panel .chat-header-bar { + margin: 0; + padding: 0 0.15rem; + min-height: 58px; + height: 58px; + background: url(../img/chat-line.png) left bottom / 375px 4px no-repeat; + box-shadow: none; + } + .room-lobby-chat-peers.chat-panel .chat-header-title.chat-header-title-img img { + display: block; + width: 90px; + height: 77px; + object-fit: contain; + } + .room-lobby-chat-peers.chat-panel .chat-header-title.chat-header-title-img::before { + content: none; + display: none; + } + .room-lobby-chat-peers.chat-panel .chat-close-btn { + width: 58px; + height: 58px; + min-width: 58px; + min-height: 58px; + max-width: 58px; + max-height: 58px; + margin-left: auto; + } + .room-lobby-chat-peers.chat-panel .chat-close-btn img { + width: 58px; + height: 58px; + object-fit: contain; + } + .room-lobby-chat-peers.chat-panel #chat-messages { + width: calc(100% - 16px); + margin-right: 16px; + padding: 0.35rem 0.7rem 0.25rem 0.15rem; + font-size: 1rem; + line-height: 1.42; + gap: 0.42rem; + margin-top: 0.15rem; + margin-bottom: 0.2rem; + scrollbar-width: auto; + scrollbar-color: rgba(250, 253, 255, 0.98) rgba(12, 26, 47, 0.45); + box-sizing: border-box; + } + .room-lobby-chat-peers.chat-panel #chat-messages::-webkit-scrollbar { + width: 15px; + } + .room-lobby-chat-peers.chat-panel #chat-messages::-webkit-scrollbar-track { + border-radius: 999px; + background: rgba(20, 44, 72, 0.45); + border: 1px solid rgba(124, 221, 255, 0.3); + } + .room-lobby-chat-peers.chat-panel #chat-messages::-webkit-scrollbar-thumb { + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, 1) 0%, rgba(241, 249, 255, 0.98) 100%); + box-shadow: 0 0 8px rgba(182, 239, 255, 0.45); + } + .room-lobby-chat-peers.chat-panel .chat-msg { + max-width: 93%; + padding: 0.22rem 0.35rem; + font-size: 0.98rem; + border-radius: 10px; + background-image: none; + background-color: transparent; + box-shadow: none; + } + .room-lobby-chat-peers.chat-panel .chat-msg-other { + color: #d5bb63; + font-weight: 500; + } + .room-lobby-chat-peers.chat-panel .chat-msg-mine { + color: #cf74d1; + background-color: rgba(39, 67, 112, 0.82); + border: 1px solid rgba(116, 206, 255, 0.2); + align-self: flex-end; + text-align: left; + padding: 0.32rem 0.62rem; + } + .room-lobby-chat-peers.chat-panel .chat-form-bar { + padding: 0.24rem 0 0; + gap: 0; + align-items: center; + justify-content: space-between; + } + .room-lobby-chat-peers.chat-panel #chat-input { + margin-left: 17px; + width: 308px; + min-width: 308px; + max-width: 308px; + height: 61px; + min-height: 61px; + max-height: 61px; + padding: 0 0.95rem; + font-size: 0.95rem; + border-radius: 0; + border: none; + background: url(../img/chatbox.png) center/100% 100% no-repeat; + color: #e8f7ff; + } + .room-lobby-chat-peers.chat-panel #chat-input::placeholder { + color: rgba(195, 211, 236, 0.66); + } + .room-lobby-chat-peers.chat-panel .chat-send-btn { + width: 102px; + height: 98px; + min-width: 102px; + min-height: 98px; + max-width: 102px; + max-height: 98px; + margin-left: -26px; + margin-right: 2px; + } + .room-lobby-chat-peers.chat-panel .chat-send-btn img { + width: 102px; + height: 98px; + } + .room-lobby-top-left-cluster { + top: max(18px, env(safe-area-inset-top)); + left: max(0px, env(safe-area-inset-left)); + gap: 0; + } + .room-lobby-exit-fixed { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 0.45rem; + margin-top: -1.4rem; + width: auto; + max-width: none; + } + .room-lobby-top-right-cluster { + top: max(16px, env(safe-area-inset-top)); + right: max(16px, env(safe-area-inset-right)); + } + .room-lobby-room-id-pill { + width: fit-content; + min-width: 0; + height: 180px; + min-height: 180px; + max-height: 180px; + max-width: none; + padding: 0 15px; + border-style: solid; + border-width: 0 60px; + border-radius: 0; + background: transparent; + border-image-source: url("../img/Room-id-BG.png"); + border-image-slice: 0 60 0 60 fill; + border-image-repeat: stretch; + box-shadow: none; + gap: 0.7rem; + align-items: center; + white-space: nowrap; + } + .room-lobby-room-id-label { + font-size: 2.05rem; + font-weight: 600; + line-height: 1; + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif; + text-transform: none; + color: #e8f7ff; + text-shadow: + 0 0 6px rgba(110, 226, 255, 0.8), + 0 0 14px rgba(44, 198, 255, 0.5); + } + #room-id-value { + font-size: 2.35rem; + line-height: 1; + font-family: 'NotoSansThai', 'Kanit', system-ui, sans-serif; + text-transform: none; + font-weight: 600; + color: #ecfbff; + text-shadow: + 0 0 7px rgba(118, 230, 255, 0.9), + 0 0 16px rgba(52, 203, 255, 0.55); + } + .room-lobby-room-id-lock { + width: 60px; + height: 65px; + min-width: 60px; + min-height: 65px; + max-width: 60px; + max-height: 65px; + aspect-ratio: 60 / 65; + font-size: 0; + color: transparent; + background: url("../img/Room-id-private.png") center / 100% 100% no-repeat; + filter: none; + } + .room-lobby-profile-frame { + width: 159px; + height: 157px; + min-width: 159px; + min-height: 157px; + max-width: 159px; + max-height: 157px; + aspect-ratio: 159 / 157; + } + .room-lobby-exit-fixed .btn-exit-icon { + width: 121px; + height: 119px; + min-width: 121px; + min-height: 119px; + max-width: 121px; + max-height: 119px; + aspect-ratio: 121 / 119; + margin-left: 13px; + margin-top: -15px; + margin-bottom: -15px; + } + body.room-lobby-page #room-players-hud { + min-width: 0; + font-size: clamp(1.35rem, 1.95vw, 1.9rem); + padding: 0.1rem 0.4rem; + } + .room-lobby-ready-fixed .ready-label-img { + width: 320px; + height: 126px; + min-width: 320px; + min-height: 126px; + max-width: 320px; + max-height: 126px; + aspect-ratio: 320 / 126; + } + .room-lobby-br-fixed { + --lobby-br-btn: 137px; + bottom: max(0.65rem, env(safe-area-inset-bottom)); + right: max(0.85rem, env(safe-area-inset-right)); + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-end; + gap: 1rem; + } + .room-lobby-br-fixed .lobby-b-extra-row { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: flex-end; + gap: 0.6rem; + order: 1; + position: static !important; + margin: 0 !important; + transform: none !important; + } + .room-lobby-br-fixed .btn-voice-icon { + order: 2; + position: static !important; + margin: 0 !important; + transform: none !important; + } + .room-lobby-br-fixed .lobby-b-extra-row { + z-index: auto; + } + .room-lobby-chat-peers.chat-panel-collapsed.chat-panel { + width: 122px; + height: 120px; + bottom: 12rem; + } + .room-lobby-chat-peers.chat-panel-collapsed .chat-close-btn { + width: 122px; + height: 120px; + min-width: 122px; + min-height: 120px; + max-width: 122px; + max-height: 120px; + aspect-ratio: 122 / 120; + margin-right: -20px; + } + .room-lobby-chat-peers.chat-panel-collapsed .chat-close-btn img { + width: 122px; + height: 120px; + aspect-ratio: 122 / 120; + } +} + +/* —— แฟ้มหลักฐาน LobbyB (รูปจาก /Main-Lobby/IMAGE/See evidence) —— */ +.lobby-evidence-overlay { + position: fixed; + inset: 0; + z-index: 10047; + display: flex; + align-items: center; + justify-content: center; + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); + box-sizing: border-box; +} +.lobby-evidence-overlay.is-hidden { + display: none !important; +} +.lobby-evidence-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 8, 22, 0.78); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; +} +.lobby-evidence-dialog { + position: relative; + z-index: 1; + width: min(96vw, 900px); + max-height: min(92vh, 920px); + pointer-events: none; +} +.lobby-evidence-dialog * { + pointer-events: auto; +} +.lobby-evidence-frame { + position: relative; + width: 100%; + aspect-ratio: 1684 / 963; + filter: drop-shadow(0 0 24px rgba(0, 255, 255, 0.12)) drop-shadow(0 0 40px rgba(255, 0, 255, 0.08)); +} +.lobby-evidence-bg { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center top; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} +.lobby-evidence-close { + position: absolute; + top: 5.5%; + right: 4%; + width: 8%; + max-width: 56px; + aspect-ratio: 1; + padding: 0; + margin: 0; + border: none; + border-radius: 10px; + background: transparent; + color: rgba(0, 255, 255, 0.95); + font-size: clamp(1.25rem, 4vw, 2rem); + line-height: 1; + cursor: pointer; + text-shadow: 0 0 12px rgba(0, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, filter 0.15s ease; +} +.lobby-evidence-close:hover { + transform: scale(1.08); + filter: brightness(1.15); +} +.lobby-evidence-tabs-layer { + position: absolute; + left: 7%; + right: 7%; + top: 15.5%; + height: 13.5%; + min-height: 36px; +} +.lobby-evidence-tabs-img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: left center; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} +.lobby-evidence-tab-hits { + position: absolute; + inset: 0; + display: flex; + flex-direction: row; + align-items: stretch; + padding-left: 1.5%; + padding-right: 0; + box-sizing: border-box; +} +.lobby-evidence-tab-hit { + flex: 0 0 13.2%; + min-width: 0; + height: 100%; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + border-radius: 8px; +} +.lobby-evidence-tab-hit:focus-visible { + outline: 2px solid rgba(0, 255, 255, 0.9); + outline-offset: 2px; +} +.lobby-evidence-tab-hit-spacer { + flex: 1 1 auto; + pointer-events: none; +} +.lobby-evidence-cards-root { + position: absolute; + left: 5%; + right: 5%; + top: 31%; + bottom: 7%; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: clamp(6px, 1.2vw, 14px); + align-items: stretch; + justify-content: center; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + padding-bottom: 4px; + box-sizing: border-box; +} +.lobby-evidence-card { + flex: 1 1 0; + min-width: min(200px, 28vw); + max-width: 320px; + display: flex; + flex-direction: column; + border-radius: 10px; + padding: clamp(6px, 1vw, 10px); + background: rgba(15, 20, 36, 0.92); + box-sizing: border-box; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 8px 24px rgba(0, 0, 0, 0.35); +} +.lobby-evidence-card--common { + box-shadow: 0 0 0 2px rgba(180, 190, 200, 0.65), 0 0 16px rgba(200, 210, 220, 0.2), 0 8px 24px rgba(0, 0, 0, 0.35); +} +.lobby-evidence-card--rare { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.85), 0 0 20px rgba(59, 130, 246, 0.35), 0 8px 24px rgba(0, 0, 0, 0.35); +} +.lobby-evidence-card--legendary { + box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.9), 0 0 22px rgba(250, 204, 21, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); +} +.lobby-evidence-card-head { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} +.lobby-evidence-card-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(0, 255, 255, 0.25), rgba(255, 0, 255, 0.15)); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; +} +.lobby-evidence-card-titles { + flex: 1; + min-width: 0; +} +.lobby-evidence-card-title-th { + font-family: 'Sarabun', 'Kanit', system-ui, sans-serif; + font-size: clamp(0.68rem, 1.35vw, 0.82rem); + font-weight: 700; + color: #e8ecff; + line-height: 1.25; + margin: 0; +} +.lobby-evidence-card-title-en { + font-family: system-ui, sans-serif; + font-size: clamp(0.58rem, 1.05vw, 0.68rem); + color: rgba(180, 190, 220, 0.85); + margin: 2px 0 0; + line-height: 1.2; +} +.lobby-evidence-card-art { + flex: 1 1 auto; + min-height: clamp(72px, 14vh, 140px); + max-height: min(22vh, 200px); + border-radius: 8px; + background: linear-gradient(160deg, rgba(30, 40, 70, 0.95), rgba(12, 16, 32, 0.98)); + border: 1px solid rgba(0, 255, 255, 0.12); + margin-bottom: 6px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(0, 255, 255, 0.35); + font-size: clamp(1.5rem, 4vw, 2.25rem); + user-select: none; +} +.lobby-evidence-card-body { + font-family: 'Sarabun', 'Kanit', system-ui, sans-serif; + font-size: clamp(0.6rem, 1.15vw, 0.72rem); + color: rgba(210, 218, 240, 0.92); + line-height: 1.4; + margin: 0 0 8px; + flex-shrink: 0; + max-height: 4.5em; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; +} +.lobby-evidence-card-foot { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: auto; + flex-shrink: 0; +} +.lobby-evidence-link { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + min-width: 0; +} +.lobby-evidence-avatar { + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(145deg, #7aa2f7, #565f89); + color: #1a1b26; + font-size: 11px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 0 2px rgba(0, 255, 255, 0.25); +} +.lobby-evidence-link-name { + font-size: clamp(0.62rem, 1.1vw, 0.74rem); + font-weight: 600; + color: #c0caf5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.lobby-evidence-foot-meta { + text-align: right; + flex-shrink: 0; +} +.lobby-evidence-rarity { + display: block; + font-size: clamp(0.55rem, 0.95vw, 0.65rem); + color: rgba(170, 180, 210, 0.9); + margin-bottom: 2px; +} +.lobby-evidence-stars { + font-size: clamp(0.65rem, 1.2vw, 0.78rem); + letter-spacing: 1px; + line-height: 1; +} +.lobby-evidence-card--common .lobby-evidence-stars { color: #d4d4d8; text-shadow: 0 0 8px rgba(200, 200, 210, 0.4); } +.lobby-evidence-card--rare .lobby-evidence-stars { color: #60a5fa; text-shadow: 0 0 10px rgba(59, 130, 246, 0.5); } +.lobby-evidence-card--legendary .lobby-evidence-stars { color: #fcd34d; text-shadow: 0 0 10px rgba(234, 179, 8, 0.55); } +@media (max-width: 720px) { + .lobby-evidence-cards-root { + flex-wrap: wrap; + overflow-y: auto; + align-content: flex-start; + } + .lobby-evidence-card { + flex: 1 1 100%; + max-width: none; + min-width: 0; + } +} + +/* —— กระดานอันดับ LobbyB (เหรียญ leaderboard-1/2/3 จาก Main-Lobby) —— */ +.lobby-rank-overlay { + position: fixed; + inset: 0; + z-index: 10048; + display: flex; + align-items: center; + justify-content: center; + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); + box-sizing: border-box; +} +.lobby-rank-overlay.is-hidden { + display: none !important; +} +.lobby-rank-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 4, 14, 0.82); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + cursor: pointer; +} +.lobby-rank-dialog { + position: relative; + z-index: 1; + width: min(96vw, 520px); + max-height: min(90vh, 720px); + pointer-events: none; +} +.lobby-rank-dialog * { + pointer-events: auto; +} +.lobby-rank-panel { + background: linear-gradient(165deg, rgba(28, 36, 58, 0.96), rgba(12, 16, 32, 0.98)); + border-radius: 16px; + border: 2px solid rgba(0, 255, 255, 0.45); + box-shadow: + 0 0 0 1px rgba(255, 0, 200, 0.25), + 0 0 32px rgba(0, 255, 255, 0.12), + 0 0 48px rgba(255, 0, 180, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + padding: clamp(10px, 2.5vw, 16px); + display: flex; + flex-direction: column; + min-height: 0; + font-family: 'Kanit', 'Sarabun', system-ui, sans-serif; +} +.lobby-rank-head { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + position: relative; + padding: 6px 44px 12px 12px; + border-bottom: 1px solid rgba(0, 255, 255, 0.28); + margin-bottom: 8px; +} +.lobby-rank-case-title { + margin: 0; + font-size: clamp(0.95rem, 2.8vw, 1.15rem); + font-weight: 700; + color: #e8f4ff; + text-align: center; + text-shadow: 0 0 12px rgba(0, 255, 255, 0.35); + line-height: 1.25; +} +.lobby-rank-close { + position: absolute; + top: 2px; + right: 4px; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 10px; + background: transparent; + color: rgba(0, 255, 255, 0.95); + font-size: 1.75rem; + line-height: 1; + cursor: pointer; + text-shadow: 0 0 14px rgba(0, 255, 255, 0.85); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, filter 0.15s ease; +} +.lobby-rank-close:hover { + transform: scale(1.06); + filter: brightness(1.12); +} +.lobby-rank-podium { + display: flex; + flex-direction: row; + align-items: flex-end; + justify-content: center; + gap: clamp(6px, 2vw, 14px); + padding: 8px 4px 14px; + flex-shrink: 0; +} +.lobby-rank-ped { + flex: 1 1 0; + min-width: 0; + max-width: 150px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 10px 6px 12px; + border-radius: 14px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(0, 255, 255, 0.22); + box-sizing: border-box; +} +.lobby-rank-ped--2 { + order: 1; + min-height: 148px; +} +.lobby-rank-ped--1 { + order: 2; + min-height: 188px; + border-color: rgba(255, 215, 0, 0.45); + box-shadow: 0 0 22px rgba(255, 200, 0, 0.12); +} +.lobby-rank-ped--3 { + order: 3; + min-height: 132px; +} +.lobby-rank-crown { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 4px; + font-size: 1.1rem; + line-height: 1; + filter: drop-shadow(0 0 6px rgba(255, 215, 0, 0.6)); +} +.lobby-rank-crown-n { + font-size: 0.85rem; + font-weight: 800; + color: #ffe066; +} +.lobby-rank-medal-wrap { + height: 36px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; +} +.lobby-rank-medal-img { + width: auto; + height: 34px; + object-fit: contain; + display: block; +} +.lobby-rank-ped-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(145deg, #7aa2f7, #565f89); + color: #1a1b26; + font-size: 1.1rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + box-shadow: 0 0 0 2px rgba(0, 255, 255, 0.3); + flex-shrink: 0; +} +.lobby-rank-ped--1 .lobby-rank-ped-avatar { + width: 56px; + height: 56px; + font-size: 1.25rem; +} +.lobby-rank-ped-name { + font-size: clamp(0.68rem, 2vw, 0.8rem); + font-weight: 600; + color: #e8ecff; + line-height: 1.2; + word-break: break-word; + max-width: 100%; +} +.lobby-rank-ped-score { + margin-top: 4px; + font-size: clamp(0.75rem, 2.2vw, 0.9rem); + font-weight: 700; + color: #fff; + text-shadow: 0 0 8px rgba(255, 255, 255, 0.2); +} +.lobby-rank-table-wrap { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + margin: 0 -4px; + padding: 0 4px; + scrollbar-width: thin; +} +.lobby-rank-table { + width: 100%; + border-collapse: collapse; + font-size: clamp(0.65rem, 1.9vw, 0.78rem); +} +.lobby-rank-table thead th { + text-align: left; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(0, 255, 255, 0.85); + font-weight: 700; + padding: 8px 6px; + border-bottom: 1px solid rgba(0, 255, 255, 0.25); + position: sticky; + top: 0; + background: linear-gradient(180deg, rgba(20, 26, 44, 0.98), rgba(20, 26, 44, 0.92)); + z-index: 1; +} +.lobby-rank-table thead th:nth-child(1) { width: 3.2rem; } +.lobby-rank-table thead th:nth-child(3) { text-align: right; width: 4rem; } +.lobby-rank-table tbody td { + padding: 7px 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + color: #e2e8f0; + vertical-align: middle; +} +.lobby-rank-table tbody td:nth-child(3) { + text-align: right; + font-weight: 600; + color: #fff; +} +.lobby-rank-table .lobby-rank-row-av { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.lobby-rank-table .lobby-rank-mini-av { + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(145deg, #9ece6a, #565f89); + color: #1a1b26; + font-size: 0.65rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 0 1px rgba(0, 255, 255, 0.2); +} +.lobby-rank-table .lobby-rank-row-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} +.lobby-rank-self { + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 12px 10px 6px; + margin-top: 6px; + border-top: 1px solid rgba(255, 0, 180, 0.42); + background: linear-gradient(90deg, rgba(255, 0, 180, 0.07), transparent); +} +.lobby-rank-self-rank { + font-weight: 800; + color: #c0caf5; + min-width: 2.5rem; + font-size: clamp(0.75rem, 2vw, 0.88rem); +} +.lobby-rank-self-av { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(145deg, #f472b6, #a855f7); + color: #fff; + font-size: 1rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 0 2px rgba(255, 100, 200, 0.45); +} +.lobby-rank-self-meta { + flex: 1; + min-width: 0; +} +.lobby-rank-self-name { + font-size: clamp(0.85rem, 2.4vw, 1rem); + font-weight: 800; + color: #fff; + line-height: 1.2; +} +.lobby-rank-self-score { + font-size: clamp(0.85rem, 2.4vw, 1rem); + font-weight: 800; + color: #ff4ecd; + text-shadow: 0 0 12px rgba(255, 78, 205, 0.45); + flex-shrink: 0; +} + +.version-tag { + position: fixed; + right: 8px; + bottom: 4px; + left: auto; + font-size: 10px; + color: #a9b1d6; + opacity: 0.7; + pointer-events: none; +} + +/* Editor — เกมตอบคำถาม */ +.quiz-editor-wrap { + margin: 1rem 0; + padding: 1rem; + background: #24283b; + border-radius: 8px; + border: 1px solid #414868; +} +.quiz-editor-wrap h3 { + margin: 0 0 0.5rem; + font-size: 1rem; + color: #7aa2f7; +} +.quiz-questions-list { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0.75rem 0; +} +.quiz-q-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 0.5rem; + align-items: center; +} +@media (max-width: 600px) { + .quiz-q-row { + grid-template-columns: 1fr; + } +} +.quiz-q-row input[type="text"] { + min-width: 0; + padding: 0.4rem 0.5rem; + border-radius: 6px; + border: 1px solid #565f89; + background: #1a1b26; + color: #c0caf5; +} +.quiz-q-row select { + padding: 0.35rem; + border-radius: 6px; + border: 1px solid #565f89; + background: #1a1b26; + color: #c0caf5; +} +.btn-quiz-add, +.quiz-q-remove { + padding: 0.35rem 0.65rem; + border-radius: 6px; + cursor: pointer; + border: 1px solid #565f89; + background: #414868; + color: #c0caf5; + font: inherit; +} +.quiz-q-remove { + font-size: 0.8rem; +} diff --git a/www/html/Game/public/TryChangeCSS/editor.html b/www/html/Game/public/TryChangeCSS/editor.html new file mode 100644 index 0000000..4358ca7 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/editor.html @@ -0,0 +1,78 @@ + + + + + + Editor ฉาก — Game + + + +
+

Editor ฉาก

+
+
+ + กบข้ามถนน = ตารางแถว · พรมแดงสุดท้าย = กระโดดหลบ · สลับลงตึก = กดจังหวะซ้อนบล็อก · ห้องโถง = วาดฉากรอผู้เล่น · พื้นที่เริ่มเกม = host ยืนช่องส้มก่อนกดเริ่ม + + + + + + + + + + + + + + + +
+ +
+ + +
+

คลิกซ้าย = วาด · คลิกขวา = ลบ · กำแพง · โซนห้ามซ้อน · โซน interactive · พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน — ผู้เล่นเข้าห้องสุ่มยืนในช่องนี้; ไม่วาด = ใช้ปุ่มตั้งจุดเกิด) · พื้นที่เริ่มเกม (เฉพาะห้องโถง สีส้ม) · เกมถามตอบ: พื้นที่โชว์คำถาม (ทอง) · สีช่อง = เลือกสี+Alpha แล้วคลิกบนกริด · «โชว์กริดในเกม» = ยกเลิก = ไม่โชว์แค่กริด/กำแพง (รูปพื้นหลังยังโชว์)

+
+
+
+

บันทึกแล้วจะได้รหัสฉาก ใช้รหัสนี้ตอนสร้างห้องในล็อบบี้

+
+
+ + + +
v —
+ + diff --git a/www/html/Game/public/TryChangeCSS/fonts/NotoSansThai-Medium.ttf b/www/html/Game/public/TryChangeCSS/fonts/NotoSansThai-Medium.ttf new file mode 100644 index 0000000..cea375a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/fonts/NotoSansThai-Medium.ttf differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-b.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-b.png new file mode 100644 index 0000000..fd12532 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-b.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-n.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-n.png new file mode 100644 index 0000000..d3d8003 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/arrow-n.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-bg.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-bg.png new file mode 100644 index 0000000..095ac60 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-cf.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-cf.png new file mode 100644 index 0000000..233ed61 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-cf.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-delete.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-delete.png new file mode 100644 index 0000000..42862fd Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-delete.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-hostname.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-hostname.png new file mode 100644 index 0000000..cf46f6b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-hostname.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line-2.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line-2.png new file mode 100644 index 0000000..614735b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line.png new file mode 100644 index 0000000..7e3a47a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-line.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scroll-bg.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scroll-bg.png new file mode 100644 index 0000000..7853dbf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scroll-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scrollbar.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scrollbar.png new file mode 100644 index 0000000..5208964 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-scrollbar.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-textfield.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-textfield.png new file mode 100644 index 0000000..a1c2bd8 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-textfield.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-1.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-1.png new file mode 100644 index 0000000..c16c113 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-2.png b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-2.png new file mode 100644 index 0000000..1add1c8 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-4-Host-Console/host-console-txt-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/btn-cf.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/btn-cf.png new file mode 100644 index 0000000..a8496fb Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/btn-cf.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/char-bg.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/char-bg.png new file mode 100644 index 0000000..48cfae0 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/char-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/cloth-bg.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/cloth-bg.png new file mode 100644 index 0000000..21660e6 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/cloth-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/coin-bg.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/coin-bg.png new file mode 100644 index 0000000..b98b3c3 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/coin-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-1.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-1.png new file mode 100644 index 0000000..23a046d Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-2.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-2.png new file mode 100644 index 0000000..7b1807e Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-3.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-3.png new file mode 100644 index 0000000..a925b05 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-3.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-4.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-4.png new file mode 100644 index 0000000..d621f2c Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-4.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-5.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-5.png new file mode 100644 index 0000000..fbab831 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-5.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-6.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-6.png new file mode 100644 index 0000000..82cfe7a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-6.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-7.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-7.png new file mode 100644 index 0000000..b4391c5 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-7.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-8.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-8.png new file mode 100644 index 0000000..27810ed Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-8.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-frame-session.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-frame-session.png new file mode 100644 index 0000000..e593733 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-frame-session.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-select.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-select.png new file mode 100644 index 0000000..83c12df Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/color-select.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-1.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-1.png new file mode 100644 index 0000000..323d0e8 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-2.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-2.png new file mode 100644 index 0000000..72e9130 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-3.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-3.png new file mode 100644 index 0000000..92885ce Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-3.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-4.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-4.png new file mode 100644 index 0000000..cfcdcaa Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-4.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-5.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-5.png new file mode 100644 index 0000000..379ddb5 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-5.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-6.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-6.png new file mode 100644 index 0000000..fa773aa Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-6.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-7.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-7.png new file mode 100644 index 0000000..c233f07 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-7.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-8.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-8.png new file mode 100644 index 0000000..cf5c4d0 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/face-8.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-bg.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-bg.png new file mode 100644 index 0000000..b467ef6 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-select.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-select.png new file mode 100644 index 0000000..2f4e785 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/item-select.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/price-frame.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/price-frame.png new file mode 100644 index 0000000..e6cec34 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/price-frame.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bar.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bar.png new file mode 100644 index 0000000..373ee6c Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bar.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bg.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bg.png new file mode 100644 index 0000000..123ab60 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/scroll-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-1.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-1.png new file mode 100644 index 0000000..ee7e5fe Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-2.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-2.png new file mode 100644 index 0000000..9fcd44e Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-3.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-3.png new file mode 100644 index 0000000..f950b78 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/skin-tone-3.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-1-face.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-1-face.png new file mode 100644 index 0000000..5842a2f Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-1-face.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-2-hair.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-2-hair.png new file mode 100644 index 0000000..d606984 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-2-hair.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-3-cloth.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-3-cloth.png new file mode 100644 index 0000000..b026756 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/tab-3-cloth.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-color-txt.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-color-txt.png new file mode 100644 index 0000000..a8d2aff Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-color-txt.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-skin-txt.png b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-skin-txt.png new file mode 100644 index 0000000..4f7eacd Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-5-Customize/theme-skin-txt.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-frame.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-frame.png new file mode 100644 index 0000000..b3eb50a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-frame.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01-a.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01-a.png new file mode 100644 index 0000000..848d876 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01-a.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01.png new file mode 100644 index 0000000..b5b8a66 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-01.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02-a.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02-a.png new file mode 100644 index 0000000..7c39fe2 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02-a.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02.png new file mode 100644 index 0000000..14ff0b6 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-02.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03-a.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03-a.png new file mode 100644 index 0000000..60b86ae Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03-a.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03.png new file mode 100644 index 0000000..f138ae2 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-03.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04-a.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04-a.png new file mode 100644 index 0000000..11060a9 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04-a.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04.png new file mode 100644 index 0000000..8f51c6d Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-04.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05-a.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05-a.png new file mode 100644 index 0000000..751662a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05-a.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05.png new file mode 100644 index 0000000..4e463ef Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-group-05.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-icon-bg.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-icon-bg.png new file mode 100644 index 0000000..31e1fcc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-icon-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-line.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-line.png new file mode 100644 index 0000000..440360b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-line.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-1.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-1.png new file mode 100644 index 0000000..0b9153b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-2.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-2.png new file mode 100644 index 0000000..5b99605 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-3.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-3.png new file mode 100644 index 0000000..e887830 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-name-3.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress-bg.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress-bg.png new file mode 100644 index 0000000..d754389 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress.png new file mode 100644 index 0000000..63877ba Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-progress.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bar.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bar.png new file mode 100644 index 0000000..985d010 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bar.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bg.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bg.png new file mode 100644 index 0000000..0aa942d Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/archiv-scroll-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-edit.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-edit.png new file mode 100644 index 0000000..e5ee041 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-edit.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-ex.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-ex.png new file mode 100644 index 0000000..69bfc10 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-ex.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-off.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-off.png new file mode 100644 index 0000000..781ec7a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-off.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-on.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-on.png new file mode 100644 index 0000000..10f3f95 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/btn-on.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame-2.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame-2.png new file mode 100644 index 0000000..603aeda Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame.png new file mode 100644 index 0000000..4bb3be5 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-char-frame.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-bg.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-bg.png new file mode 100644 index 0000000..103bfa2 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-coin.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-coin.png new file mode 100644 index 0000000..95ef04f Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/profile-pop-coin.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-frame.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-frame.png new file mode 100644 index 0000000..c90cbbf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-frame.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-1.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-1.png new file mode 100644 index 0000000..595e796 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-1.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-2.png b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-2.png new file mode 100644 index 0000000..926f2aa Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/03-6-Profile/setting-txt-2.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat--header.png b/www/html/Game/public/TryChangeCSS/img/AI-chat--header.png new file mode 100644 index 0000000..f86dce1 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat--header.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat-BG.png b/www/html/Game/public/TryChangeCSS/img/AI-chat-BG.png new file mode 100644 index 0000000..ceec8f7 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat-BG.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat-bubble-thep.png b/www/html/Game/public/TryChangeCSS/img/AI-chat-bubble-thep.png new file mode 100644 index 0000000..e1998dc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat-bubble-thep.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat-line.png b/www/html/Game/public/TryChangeCSS/img/AI-chat-line.png new file mode 100644 index 0000000..f47ecc8 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat-line.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar-bg.png b/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar-bg.png new file mode 100644 index 0000000..02a7b22 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar.png b/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar.png new file mode 100644 index 0000000..afe43a0 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/AI-chat-scrollbar.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/BG-fade.png b/www/html/Game/public/TryChangeCSS/img/BG-fade.png new file mode 100644 index 0000000..d60b55d Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/BG-fade.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/BTN Exit Room.png b/www/html/Game/public/TryChangeCSS/img/BTN Exit Room.png new file mode 100644 index 0000000..752a6c5 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/BTN Exit Room.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/BTN-SettingProfile.png b/www/html/Game/public/TryChangeCSS/img/BTN-SettingProfile.png new file mode 100644 index 0000000..8d1fa86 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/BTN-SettingProfile.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/BTN-howto.png b/www/html/Game/public/TryChangeCSS/img/BTN-howto.png new file mode 100644 index 0000000..ef344b3 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/BTN-howto.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/Room-id-BG.png b/www/html/Game/public/TryChangeCSS/img/Room-id-BG.png new file mode 100644 index 0000000..1258239 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/Room-id-BG.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/Room-id-private.png b/www/html/Game/public/TryChangeCSS/img/Room-id-private.png new file mode 100644 index 0000000..b67280a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/Room-id-private.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/Scene-lobby-main.png b/www/html/Game/public/TryChangeCSS/img/Scene-lobby-main.png new file mode 100644 index 0000000..fadbc89 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/Scene-lobby-main.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/ai-chat-char.png b/www/html/Game/public/TryChangeCSS/img/ai-chat-char.png new file mode 100644 index 0000000..2ce7afe Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/ai-chat-char.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/ai-chat-header.png b/www/html/Game/public/TryChangeCSS/img/ai-chat-header.png new file mode 100644 index 0000000..2ce7afe Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/ai-chat-header.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/ai-chat-title-label.png b/www/html/Game/public/TryChangeCSS/img/ai-chat-title-label.png new file mode 100644 index 0000000..f86dce1 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/ai-chat-title-label.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-ai-chatbot.png b/www/html/Game/public/TryChangeCSS/img/btn-ai-chatbot.png new file mode 100644 index 0000000..b937cfc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-ai-chatbot.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-chat.png b/www/html/Game/public/TryChangeCSS/img/btn-chat.png new file mode 100644 index 0000000..0b94b0d Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-chat.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-customize.png b/www/html/Game/public/TryChangeCSS/img/btn-customize.png new file mode 100644 index 0000000..5ae8826 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-customize.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-exit-room.png b/www/html/Game/public/TryChangeCSS/img/btn-exit-room.png new file mode 100644 index 0000000..752a6c5 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-exit-room.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-howto-room.png b/www/html/Game/public/TryChangeCSS/img/btn-howto-room.png new file mode 100644 index 0000000..ef344b3 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-howto-room.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-mic-mute.png b/www/html/Game/public/TryChangeCSS/img/btn-mic-mute.png new file mode 100644 index 0000000..e45b302 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-mic-mute.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-mic-on.png b/www/html/Game/public/TryChangeCSS/img/btn-mic-on.png new file mode 100644 index 0000000..2bb5a11 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-mic-on.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-profile-bg.png b/www/html/Game/public/TryChangeCSS/img/btn-profile-bg.png new file mode 100644 index 0000000..6c8810c Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-profile-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-profile-set.png b/www/html/Game/public/TryChangeCSS/img/btn-profile-set.png new file mode 100644 index 0000000..2d6970e Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-profile-set.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-ready-active.png b/www/html/Game/public/TryChangeCSS/img/btn-ready-active.png new file mode 100644 index 0000000..cfd712c Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-ready-active.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-ready-idle.png b/www/html/Game/public/TryChangeCSS/img/btn-ready-idle.png new file mode 100644 index 0000000..200f47b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-ready-idle.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-ready.png b/www/html/Game/public/TryChangeCSS/img/btn-ready.png new file mode 100644 index 0000000..200f47b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-ready.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/btn-setting-host.png b/www/html/Game/public/TryChangeCSS/img/btn-setting-host.png new file mode 100644 index 0000000..4c0e417 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/btn-setting-host.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/bubble-00.png b/www/html/Game/public/TryChangeCSS/img/bubble-00.png new file mode 100644 index 0000000..ac2ec6a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/bubble-00.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/bubble-01.png b/www/html/Game/public/TryChangeCSS/img/bubble-01.png new file mode 100644 index 0000000..4d58ecc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/bubble-01.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/bubble-02.png b/www/html/Game/public/TryChangeCSS/img/bubble-02.png new file mode 100644 index 0000000..8d76fd2 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/bubble-02.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/bubble-03.png b/www/html/Game/public/TryChangeCSS/img/bubble-03.png new file mode 100644 index 0000000..96fa1dc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/bubble-03.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/characters/README.txt b/www/html/Game/public/TryChangeCSS/img/characters/README.txt new file mode 100644 index 0000000..5d95684 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/img/characters/README.txt @@ -0,0 +1,4 @@ +วางไฟล์รูปตัวละครในโฟลเดอร์นี้ (png, jpg, jpeg, gif, webp) +ชื่อไฟล์จะแสดงเป็นตัวเลือกในเมนู "เลือกตัวละคร" + +ตัวอย่าง: เอารูปจากโฟลเดอร์ test-dev มาวางที่นี่ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-bg.png b/www/html/Game/public/TryChangeCSS/img/chat-bg.png new file mode 100644 index 0000000..baa4aaf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-btn-send.png b/www/html/Game/public/TryChangeCSS/img/chat-btn-send.png new file mode 100644 index 0000000..548560a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-btn-send.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-bubble-friend.png b/www/html/Game/public/TryChangeCSS/img/chat-bubble-friend.png new file mode 100644 index 0000000..72c8acc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-bubble-friend.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-bubble-me.png b/www/html/Game/public/TryChangeCSS/img/chat-bubble-me.png new file mode 100644 index 0000000..b3dbe89 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-bubble-me.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-close-btn.png b/www/html/Game/public/TryChangeCSS/img/chat-close-btn.png new file mode 100644 index 0000000..3b41511 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-close-btn.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-header-title.png b/www/html/Game/public/TryChangeCSS/img/chat-header-title.png new file mode 100644 index 0000000..1baeb9a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-header-title.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-header.png b/www/html/Game/public/TryChangeCSS/img/chat-header.png new file mode 100644 index 0000000..1baeb9a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-header.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-input-bg.png b/www/html/Game/public/TryChangeCSS/img/chat-input-bg.png new file mode 100644 index 0000000..baa4aaf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-input-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-line.png b/www/html/Game/public/TryChangeCSS/img/chat-line.png new file mode 100644 index 0000000..f4fb9bb Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-line.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-msg-bg-alpha.png b/www/html/Game/public/TryChangeCSS/img/chat-msg-bg-alpha.png new file mode 100644 index 0000000..baa4aaf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-msg-bg-alpha.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-msg-bg.png b/www/html/Game/public/TryChangeCSS/img/chat-msg-bg.png new file mode 100644 index 0000000..baa4aaf Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-msg-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-scrollbar-bg.png b/www/html/Game/public/TryChangeCSS/img/chat-scrollbar-bg.png new file mode 100644 index 0000000..58b9a30 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-scrollbar-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chat-scrollbar.png b/www/html/Game/public/TryChangeCSS/img/chat-scrollbar.png new file mode 100644 index 0000000..c59c10b Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chat-scrollbar.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/chatbox.png b/www/html/Game/public/TryChangeCSS/img/chatbox.png new file mode 100644 index 0000000..5f47331 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/chatbox.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/default-shadow-down.png b/www/html/Game/public/TryChangeCSS/img/default-shadow-down.png new file mode 100644 index 0000000..84a87a0 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/default-shadow-down.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/default-shadow-left.png b/www/html/Game/public/TryChangeCSS/img/default-shadow-left.png new file mode 100644 index 0000000..32cffe8 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/default-shadow-left.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/default-shadow-right.png b/www/html/Game/public/TryChangeCSS/img/default-shadow-right.png new file mode 100644 index 0000000..188bc60 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/default-shadow-right.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/default-shadow-up.png b/www/html/Game/public/TryChangeCSS/img/default-shadow-up.png new file mode 100644 index 0000000..36f2c91 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/default-shadow-up.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/host-icon.png b/www/html/Game/public/TryChangeCSS/img/host-icon.png new file mode 100644 index 0000000..5471aa4 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/host-icon.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/icon-ready.png b/www/html/Game/public/TryChangeCSS/img/icon-ready.png new file mode 100644 index 0000000..d135569 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/icon-ready.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/lobby-b/bg-item.png b/www/html/Game/public/TryChangeCSS/img/lobby-b/bg-item.png new file mode 100644 index 0000000..b18e8bc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/lobby-b/bg-item.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-evidence.png b/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-evidence.png new file mode 100644 index 0000000..55c1cef Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-evidence.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-rank.png b/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-rank.png new file mode 100644 index 0000000..d381808 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/lobby-b/icon-rank.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/lobby-icon-ready.png b/www/html/Game/public/TryChangeCSS/img/lobby-icon-ready.png new file mode 100644 index 0000000..d135569 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/lobby-icon-ready.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/mic-permission-bg.png b/www/html/Game/public/TryChangeCSS/img/mic-permission-bg.png new file mode 100644 index 0000000..14b2343 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/mic-permission-bg.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-allow.png b/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-allow.png new file mode 100644 index 0000000..110d329 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-allow.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-close.png b/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-close.png new file mode 100644 index 0000000..eed4101 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/mic-permission-btn-close.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/mic-permission-txt.png b/www/html/Game/public/TryChangeCSS/img/mic-permission-txt.png new file mode 100644 index 0000000..d5e160c Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/mic-permission-txt.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/shadow-char.png b/www/html/Game/public/TryChangeCSS/img/shadow-char.png new file mode 100644 index 0000000..fa579c4 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/shadow-char.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/speaking-bubble-00.png b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-00.png new file mode 100644 index 0000000..ac2ec6a Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-00.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/speaking-bubble-01.png b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-01.png new file mode 100644 index 0000000..4d58ecc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-01.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/speaking-bubble-02.png b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-02.png new file mode 100644 index 0000000..8d76fd2 Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-02.png differ diff --git a/www/html/Game/public/TryChangeCSS/img/speaking-bubble-03.png b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-03.png new file mode 100644 index 0000000..96fa1dc Binary files /dev/null and b/www/html/Game/public/TryChangeCSS/img/speaking-bubble-03.png differ diff --git a/www/html/Game/public/TryChangeCSS/index.html b/www/html/Game/public/TryChangeCSS/index.html new file mode 100644 index 0000000..3eb4b58 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/index.html @@ -0,0 +1,18 @@ + + + + + + Game + + + +
+

Game

+

เลือกตัวละคร · ล็อบบี้ (สร้างห้อง/เข้าร่วม)

+
+ + +
v —
+ + diff --git a/www/html/Game/public/TryChangeCSS/js/character.js b/www/html/Game/public/TryChangeCSS/js/character.js new file mode 100644 index 0000000..3ee9fe5 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/character.js @@ -0,0 +1,414 @@ +(function () { + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const STORAGE_KEY = 'gameCharacterId'; + const grid = document.getElementById('character-grid'); + const status = document.getElementById('char-status'); + const hint = document.getElementById('char-hint'); + const uploadStatus = document.getElementById('upload-status'); + const dirs = ['up', 'down', 'left', 'right']; + // ลำดับเลเยอร์จากหลังมาหน้า: shadow → body → head → hair → face (เงาอยู่ล่างสุด) + const layers = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; + const DEFAULT_SHADOW_URL = SERVER + '/img/default-shadow-'; + const fileInputs = {}; + const previews = {}; + const layerFileInputs = {}; + const layerPreviews = {}; + dirs.forEach(d => { + fileInputs[d] = document.getElementById('file-' + d); + previews[d] = document.getElementById('preview-' + d); + layerFileInputs[d] = {}; + layers.forEach(layer => { + const inputId = 'layer-' + d + '-' + layer; + const el = document.getElementById(inputId); + if (el) layerFileInputs[d][layer] = el; + }); + layerPreviews[d] = document.getElementById('preview-' + d + '-layer'); + }); + + function getSelected() { + try { return localStorage.getItem(STORAGE_KEY) || ''; } catch (e) { return ''; } + } + function setSelected(id) { + try { + if (id) localStorage.setItem(STORAGE_KEY, id); + else localStorage.removeItem(STORAGE_KEY); + } catch (e) {} + } + + dirs.forEach(d => { + const input = fileInputs[d]; + const prev = previews[d]; + if (input && prev) { + input.addEventListener('change', () => { + const files = input.files ? Array.from(input.files) : []; + const f = files[0]; + if (!f) { prev.src = ''; prev.title = ''; return; } + const r = new FileReader(); + r.onload = () => { + prev.src = r.result; + prev.title = files.length > 1 ? (files.length + ' เฟรม') : ''; + }; + r.readAsDataURL(f); + }); + } + }); + + function readImageFromFile(file) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('load image fail')); + img.src = r.result; + }; + r.onerror = () => reject(new Error('read file fail')); + r.readAsDataURL(file); + }); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result); + r.onerror = () => reject(new Error('read file fail')); + r.readAsDataURL(file); + }); + } + + /** ส่งเลเยอร์ดิบต่อเฟรมให้เซิร์ฟเวอร์บันทึก — หน้า play จะย้อมสีแยก body / hair / head ได้ */ + async function collectLayerFramesForDirection(dir, maxFrames) { + const inputs = layerFileInputs[dir]; + if (!inputs || maxFrames < 1) return null; + const frames = []; + for (let i = 0; i < maxFrames; i++) { + const layerObj = {}; + for (const layer of layers) { + const input = inputs[layer]; + const files = input && input.files ? Array.from(input.files) : []; + if (!files.length) continue; + const file = files[Math.min(i, files.length - 1)]; + try { + layerObj[layer] = await readFileAsDataUrl(file); + } catch (e) { + // ข้ามเลเยอร์นี้เฟรมนี้ + } + } + frames.push(layerObj); + } + return frames; + } + + function loadImageFromUrl(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('load url fail')); + img.src = url; + }); + } + + async function composeLayeredFrame(dir, frameIndex) { + const inputs = layerFileInputs[dir] || {}; + const imagePromises = []; + layers.forEach(layer => { + const input = inputs[layer]; + const files = input && input.files ? Array.from(input.files) : []; + const hasFile = files.length > 0; + const file = hasFile ? files[Math.min(frameIndex, files.length - 1)] : null; + if (layer === 'shadow') { + imagePromises.push(file ? readImageFromFile(file) : loadImageFromUrl(DEFAULT_SHADOW_URL + dir + '.png').catch(() => null)); + } else { + if (!file) { + imagePromises.push(Promise.resolve(null)); + return; + } + imagePromises.push(readImageFromFile(file).catch(() => null)); + } + }); + const resolved = await Promise.all(imagePromises); + const images = resolved.filter(Boolean); + if (!images.length) return null; + const base = images[0]; + const w = base.width || 64; + const h = base.height || 64; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + resolved.forEach(img => { + if (img) ctx.drawImage(img, 0, 0, w, h); + }); + return canvas.toDataURL('image/png'); + } + + function refreshLayerPreviewForDir(dir) { + const prev = layerPreviews[dir]; + if (!prev) return; + // ใช้เฟรมแรกเป็นตัวอย่าง + composeLayeredFrame(dir, 0).then(dataUrl => { + if (dataUrl) { + prev.src = dataUrl; + prev.title = 'ตัวอย่างเลเยอร์เฟรมที่ 1'; + } else { + prev.src = ''; + prev.title = ''; + } + }).catch(() => { + prev.src = ''; + prev.title = ''; + }); + } + + // เมื่อมีการเปลี่ยนไฟล์เลเยอร์ใด ๆ ให้รีเฟรช preview ของทิศนั้น + dirs.forEach(d => { + const inputs = layerFileInputs[d]; + if (!inputs) return; + layers.forEach(layer => { + const input = inputs[layer]; + if (!input) return; + input.addEventListener('change', () => { + refreshLayerPreviewForDir(d); + }); + }); + }); + + function refreshList() { + if (status) status.textContent = 'โหลดรายการตัวละคร...'; + fetch(SERVER + '/api/characters') + .then(r => r.json()) + .then(list => { + if (!Array.isArray(list)) throw new Error('ไม่ใช่ array'); + if (status) status.textContent = ''; + if (hint) hint.style.display = list.length === 0 ? 'block' : 'none'; + const selected = getSelected(); + if (!grid) return; + grid.innerHTML = ''; + list.forEach(char => { + const div = document.createElement('div'); + div.className = 'character-item' + (selected === char.id ? ' selected' : ''); + div.setAttribute('data-id', char.id); + + const previewsDiv = document.createElement('div'); + previewsDiv.className = 'previews'; + dirs.forEach(d => { + const img = document.createElement('img'); + img.src = SERVER + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png'; + img.alt = d; + img.onerror = () => { img.style.opacity = '0.4'; }; + previewsDiv.appendChild(img); + }); + + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = char.name || char.id; + + const actions = document.createElement('div'); + actions.className = 'character-actions'; + const btnEdit = document.createElement('button'); + btnEdit.type = 'button'; + btnEdit.textContent = 'แก้ไข'; + btnEdit.className = 'char-edit-btn'; + btnEdit.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (charName) charName.value = char.id; + // แสดง preview รูปปัจจุบันในส่วนอัปโหลด + const ts = '?v=' + Date.now(); + dirs.forEach(d => { + const prev = previews[d]; + if (!prev) return; + const url = SERVER + '/img/characters/' + encodeURIComponent(char.id) + '_' + d + '.png' + ts; + prev.onerror = function () { prev.src = 'data:image/svg+xml,' + encodeURIComponent('ไม่มี'); prev.onerror = null; }; + prev.src = url; + }); + if (uploadStatus) uploadStatus.textContent = 'เลือกรูปใหม่เพื่ออัปเดต "' + (char.name || char.id) + '" แล้วกดอัปโหลด'; + const firstSection = document.querySelector('.char-upload-simple, .char-upload'); + if (firstSection) firstSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + const btnDelete = document.createElement('button'); + btnDelete.type = 'button'; + btnDelete.textContent = 'ลบ'; + btnDelete.className = 'char-delete-btn'; + btnDelete.addEventListener('click', (ev) => { + ev.stopPropagation(); + const ok = window.confirm('ต้องการลบตัวละคร "' + (char.name || char.id) + '" จริงหรือไม่?'); + if (!ok) return; + if (uploadStatus) uploadStatus.textContent = 'กำลังลบตัวละคร...'; + fetch(SERVER + '/api/characters/' + encodeURIComponent(char.id), { + method: 'DELETE' + }) + .then(async (r) => { + const text = await r.text(); + let data; + try { data = JSON.parse(text); } catch (e) { data = { ok: false, error: text || r.status }; } + if (!r.ok || !data.ok) { + if (uploadStatus) uploadStatus.textContent = data.error || ('ลบไม่สำเร็จ (' + r.status + ')'); + return; + } + if (uploadStatus) uploadStatus.textContent = 'ลบตัวละครแล้ว'; + const current = getSelected(); + if (current === char.id) { + setSelected(''); + } + refreshList(); + }) + .catch((err) => { + console.error('Delete character failed', err); + if (uploadStatus) uploadStatus.textContent = 'ลบไม่สำเร็จ: ' + (err.message || ''); + }); + }); + actions.appendChild(btnEdit); + actions.appendChild(btnDelete); + + div.appendChild(previewsDiv); + div.appendChild(name); + div.appendChild(actions); + + div.addEventListener('click', () => { + setSelected(char.id); + grid.querySelectorAll('.character-item').forEach(el => el.classList.remove('selected')); + div.classList.add('selected'); + }); + grid.appendChild(div); + }); + }) + .catch(() => { + if (status) status.textContent = 'โหลดรายการตัวละครไม่ได้'; + if (hint) hint.style.display = 'block'; + }); + } + + refreshList(); + + const btnUpload = document.getElementById('btn-upload'); + const charName = document.getElementById('char-name'); + const btnUploadLayered = document.getElementById('btn-upload-layered'); + + function uploadCharacterPayload(payload) { + // ถ้าทิศไหนมีแค่ 1 เฟรม ให้ส่งเป็น string เพื่อให้เข้ากับ server แบบเก่า + dirs.forEach(d => { + if (Array.isArray(payload[d]) && payload[d].length === 1) { + payload[d] = payload[d][0]; + } + if (Array.isArray(payload[d]) && payload[d].length === 0) { + delete payload[d]; + } + }); + if (uploadStatus) uploadStatus.textContent = 'กำลังอัปโหลด...'; + return fetch(SERVER + '/api/characters/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(async (r) => { + const text = await r.text(); + let data; + try { data = JSON.parse(text); } catch (e) { data = { ok: false, error: text || r.status }; } + if (!r.ok) { + console.error('Upload error', r.status, data.error || text); + if (uploadStatus) uploadStatus.textContent = data.error || 'ข้อผิดพลาด ' + r.status; + return; + } + if (data.ok) { + if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + (data.characterId || ''); + dirs.forEach(d => { + if (fileInputs[d]) fileInputs[d].value = ''; + if (previews[d]) previews[d].src = ''; + const inputs = layerFileInputs[d]; + if (inputs) { + layers.forEach(layer => { + if (inputs[layer]) inputs[layer].value = ''; + }); + } + if (layerPreviews[d]) layerPreviews[d].src = ''; + }); + if (charName) charName.value = ''; + refreshList(); + } else { + if (uploadStatus) uploadStatus.textContent = data.error || 'อัปโหลดไม่สำเร็จ'; + } + }) + .catch((err) => { + console.error('Upload failed', err); + if (uploadStatus) uploadStatus.textContent = 'อัปโหลดไม่สำเร็จ: ' + (err.message || ''); + }); + } + + if (btnUpload) { + btnUpload.addEventListener('click', () => { + const payload = { name: (charName && charName.value || '').trim() }; + let hasAny = false; + const readers = []; + dirs.forEach(d => { + const input = fileInputs[d]; + const files = input && input.files ? Array.from(input.files) : []; + if (!files.length) return; + hasAny = true; + payload[d] = []; + files.forEach((f, idx) => { + readers.push(new Promise(resolve => { + const r = new FileReader(); + r.onload = () => { payload[d][idx] = r.result; resolve(); }; + r.readAsDataURL(f); + })); + }); + }); + if (!hasAny) { + if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปอย่างน้อย 1 ทิศ'; + return; + } + Promise.all(readers).then(() => { + uploadCharacterPayload(payload); + }); + }); + } + + if (btnUploadLayered) { + btnUploadLayered.addEventListener('click', () => { + const payload = { name: (charName && charName.value || '').trim() }; + let hasAny = false; + const tasks = []; + const layerCollectTasks = []; + dirs.forEach(d => { + const inputs = layerFileInputs[d]; + if (!inputs) return; + let maxFrames = 0; + layers.forEach(layer => { + const input = inputs[layer]; + const len = input && input.files ? input.files.length : 0; + if (len > maxFrames) maxFrames = len; + }); + if (maxFrames === 0) return; + hasAny = true; + payload[d] = []; + layerCollectTasks.push( + collectLayerFramesForDirection(d, maxFrames).then((lf) => { + if (lf && lf.some(fr => fr && Object.keys(fr).length > 0)) { + payload.layerFrames = payload.layerFrames || {}; + payload.layerFrames[d] = lf; + } + }) + ); + for (let i = 0; i < maxFrames; i++) { + tasks.push( + composeLayeredFrame(d, i).then(dataUrl => { + if (dataUrl) { + payload[d][i] = dataUrl; + } + }) + ); + } + }); + if (!hasAny) { + if (uploadStatus) uploadStatus.textContent = 'กรุณาเลือกรูปเลเยอร์อย่างน้อย 1 ทิศ'; + return; + } + if (uploadStatus) uploadStatus.textContent = 'กำลังรวมเลเยอร์และอัปโหลด...'; + Promise.all([...tasks, ...layerCollectTasks]).then(() => { + uploadCharacterPayload(payload); + }); + }); + } +})(); diff --git a/www/html/Game/public/TryChangeCSS/js/editor.js b/www/html/Game/public/TryChangeCSS/js/editor.js new file mode 100644 index 0000000..dfaac50 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/editor.js @@ -0,0 +1,850 @@ +(function () { + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const params = new URLSearchParams(window.location.search); + const mapId = params.get('id'); + const editorEmbed = params.get('embed') === '1' || params.get('from') === 'admin'; + if (editorEmbed) { + document.documentElement.classList.add('editor-embed-admin'); + document.body.classList.add('editor-embed-admin'); + } + + function buildEditorSearch(nextMapId) { + const p = new URLSearchParams(); + if (nextMapId) p.set('id', nextMapId); + if (editorEmbed) p.set('embed', '1'); + const s = p.toString(); + return s ? ('?' + s) : ''; + } + + const canvas = document.getElementById('editor-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const mapLoadEl = document.getElementById('map-load'); + const mapName = document.getElementById('map-name'); + const mapW = document.getElementById('map-w'); + const mapH = document.getElementById('map-h'); + const tileSizeEl = document.getElementById('tile-size'); + const characterCellsEl = document.getElementById('character-cells'); + const drawModeEl = document.getElementById('draw-mode'); + const gameTypeEl = document.getElementById('game-type'); + const statusEl = document.getElementById('editor-status'); + + let tileSize = 32, width = 20, height = 15, characterCells = 1; + let gameType = 'zep'; + let lanes = []; + let ground = [], objects = [], blockPlayer = [], interactive = [], startGameArea = [], spawnArea = [], spawn = { x: 1, y: 1 }; + let quizTrueArea = [], quizFalseArea = [], quizQuestionArea = []; + let cellColors = []; + let gauntletPlayerSpawns = []; + let showMapInGame = true; + let backgroundImage = null; + let backgroundImageImg = null; + let isSpawnMode = false; + let editorFroggerAnimationId = null; + + function ensureInteractive() { + if (!interactive.length || interactive.length !== height) { + const existing = interactive.slice().map(r => r && r.slice()); + interactive = []; + for (let y = 0; y < height; y++) { + const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); + interactive.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!interactive[y] || interactive[y].length !== width) interactive[y] = Array(width).fill(0); + } + } + } + + function ensureQuizAreas() { + if (!quizTrueArea.length || quizTrueArea.length !== height) { + const existingT = quizTrueArea.slice().map(r => r && r.slice()); + quizTrueArea = []; + for (let y = 0; y < height; y++) { + const row = existingT[y] && existingT[y].length === width ? existingT[y].slice() : Array(width).fill(0); + quizTrueArea.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!quizTrueArea[y] || quizTrueArea[y].length !== width) quizTrueArea[y] = Array(width).fill(0); + } + } + if (!quizFalseArea.length || quizFalseArea.length !== height) { + const existingF = quizFalseArea.slice().map(r => r && r.slice()); + quizFalseArea = []; + for (let y = 0; y < height; y++) { + const row = existingF[y] && existingF[y].length === width ? existingF[y].slice() : Array(width).fill(0); + quizFalseArea.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!quizFalseArea[y] || quizFalseArea[y].length !== width) quizFalseArea[y] = Array(width).fill(0); + } + } + if (!quizQuestionArea.length || quizQuestionArea.length !== height) { + const existingQ = quizQuestionArea.slice().map(r => r && r.slice()); + quizQuestionArea = []; + for (let y = 0; y < height; y++) { + const row = existingQ[y] && existingQ[y].length === width ? existingQ[y].slice() : Array(width).fill(0); + quizQuestionArea.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!quizQuestionArea[y] || quizQuestionArea[y].length !== width) quizQuestionArea[y] = Array(width).fill(0); + } + } + } + + function ensureStartGameArea() { + if (!startGameArea.length || startGameArea.length !== height) { + const existing = startGameArea.slice().map(r => r && r.slice()); + startGameArea = []; + for (let y = 0; y < height; y++) { + const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); + startGameArea.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!startGameArea[y] || startGameArea[y].length !== width) startGameArea[y] = Array(width).fill(0); + } + } + } + + function sanitizeGauntletPlayerSpawns() { + gauntletPlayerSpawns = gauntletPlayerSpawns + .filter((s) => s && Number.isFinite(s.x) && Number.isFinite(s.y) && + s.x >= 0 && s.x < width && s.y >= 0 && s.y < height && + !(objects[s.y] && objects[s.y][s.x] === 1)) + .map((s) => ({ x: Math.floor(s.x), y: Math.floor(s.y) })) + .slice(0, 6); + } + + function ensureSpawnArea() { + if (!spawnArea.length || spawnArea.length !== height) { + const existing = spawnArea.slice().map(r => r && r.slice()); + spawnArea = []; + for (let y = 0; y < height; y++) { + const row = existing[y] && existing[y].length === width ? existing[y].slice() : Array(width).fill(0); + spawnArea.push(row); + } + } else { + for (let y = 0; y < height; y++) { + if (!spawnArea[y] || spawnArea[y].length !== width) spawnArea[y] = Array(width).fill(0); + } + } + } + + function buildDefaultLanes() { + const arr = []; + for (let y = 0; y < height; y++) { + if (y === 0) arr.push({ y: 0, type: 'goal' }); + else if (y === height - 1) arr.push({ y: height - 1, type: 'spawn' }); + else arr.push({ y, type: 'safe', speed: 1, dir: 1 }); + } + return arr; + } + + function ensureLanes() { + if (lanes.length !== height) { + const existing = lanes.slice(); + lanes = []; + for (let y = 0; y < height; y++) { + const old = existing.find(l => l.y === y); + if (old) lanes.push({ ...old, y }); + else lanes.push(y === 0 ? { y: 0, type: 'goal' } : y === height - 1 ? { y: height - 1, type: 'spawn' } : { y, type: 'safe', speed: 1, dir: 1 }); + } + } + } + + function getVehiclePositionsEditor(lane, 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 renderLanesTable() { + const wrap = document.getElementById('frogger-lanes-table'); + const froggerWrap = document.getElementById('frogger-lanes-wrap'); + if (!wrap || !froggerWrap) return; + ensureLanes(); + wrap.innerHTML = ''; + const table = document.createElement('table'); + table.innerHTML = 'แถว (y)ประเภทความเร็วทิศทางระยะห่าง'; + const tbody = table.querySelector('tbody'); + lanes.forEach((lane) => { + const tr = document.createElement('tr'); + const isRoadOrWater = lane.type === 'road' || lane.type === 'water'; + const spacingVal = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5); + tr.innerHTML = + '' + lane.y + '' + + '' + + '' + + '' + + ''; + tbody.appendChild(tr); + }); + wrap.appendChild(table); + wrap.querySelectorAll('.lane-type').forEach(sel => { + sel.addEventListener('change', function () { + const y = parseInt(this.dataset.y, 10); + const lane = lanes.find(l => l.y === y); + if (lane) { lane.type = this.value; lane.speed = lane.speed != null ? lane.speed : 1; lane.dir = lane.dir != null ? lane.dir : 1; if (lane.type === 'road' || lane.type === 'water') lane.spacing = lane.spacing != null ? lane.spacing : (lane.type === 'road' ? 3 : 2.5); renderLanesTable(); draw(); } + }); + }); + wrap.querySelectorAll('.lane-speed').forEach(inp => { + inp.addEventListener('input', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) lane.speed = parseFloat(this.value) || 1; draw(); }); + }); + wrap.querySelectorAll('.lane-dir').forEach(sel => { + sel.addEventListener('change', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) lane.dir = parseInt(this.value, 10) || 1; draw(); }); + }); + wrap.querySelectorAll('.lane-spacing').forEach(inp => { + inp.addEventListener('input', function () { const y = parseInt(this.dataset.y, 10); const lane = lanes.find(l => l.y === y); if (lane) { lane.spacing = parseFloat(this.value) || 2; draw(); } }); + }); + } + + function toggleFroggerUI() { + const froggerWrap = document.getElementById('frogger-lanes-wrap'); + const hint = document.getElementById('game-type-hint'); + const legEl = document.getElementById('frogger-lanes-legend'); + const dqTrue = document.getElementById('draw-mode-option-quiz-true'); + const dqFalse = document.getElementById('draw-mode-option-quiz-false'); + const dqQ = document.getElementById('draw-mode-option-quiz-question'); + if (!froggerWrap) return; + const gt = gameTypeEl ? gameTypeEl.value : 'zep'; + if (dqTrue) dqTrue.hidden = gt !== 'quiz'; + if (dqFalse) dqFalse.hidden = gt !== 'quiz'; + if (dqQ) dqQ.hidden = gt !== 'quiz'; + if (gt === 'frogger') { + froggerWrap.style.display = 'block'; + if (legEl) legEl.innerHTML = 'แถวบน (y=0) = ปลายทาง · แถวล่าง = จุดเกิด · ถนน/น้ำ ตั้งความเร็ว ทิศทาง และ ระยะห่าง (ยิ่งมากยิ่งห่าง เกมง่ายขึ้น)'; + if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'แถวบน = ปลายทาง · แถวล่าง = จุดเกิด · ตั้งถนน/น้ำด้านล่าง'; } + ensureLanes(); + renderLanesTable(); + draw(); + if (editorFroggerAnimationId != null) cancelAnimationFrame(editorFroggerAnimationId); + editorFroggerAnimationId = requestAnimationFrame(editorFroggerTick); + } else if (gt === 'gauntlet') { + froggerWrap.style.display = 'none'; + if (legEl) legEl.innerHTML = 'พรมแดงสุดท้าย: ลำดับเกิดกำหนดแค่ แถว (y) — ในเกมทุกคนยืน คอลัมน์ x เดียวกัน (ซ้ายสุดจากจุดที่วาด) ไม่เฉียง'; + if (hint) { + hint.style.display = 'inline'; + hint.innerHTML = 'โหมด พรมแดง — อุปสรรคเลนเฉพาะแถวที่มีคน + เลเซอร์ทุกแถว · ชนถอยซ้าย · ลำดับเกิด 1–6 / ช่องฟ้า · Space / W / ↑ กระโดด'; + } + if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } + draw(); + } else if (gt === 'lobby') { + froggerWrap.style.display = 'none'; + if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'วาดห้องโถงพื้นที่เริ่มเกม (ส้ม) = host ยืนก่อนกดเริ่ม · พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน) = ผู้เล่นใหม่สุ่มเกิดในช่องนั้น'; } + if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } + draw(); + } else if (gt === 'quiz') { + froggerWrap.style.display = 'none'; + if (hint) { + hint.style.display = 'inline'; + hint.innerHTML = 'วาดโซนถูก (ฟ้า) / โซนผิด (ชมพู) / พื้นที่โชว์คำถาม (ทอง) · พื้นที่สุ่มจุดเกิด (ฟ้าอ่อน) = ผู้เล่นเข้าห้องสุ่มยืนในช่องนั้น · คำถามและเวลาตั้งที่ Admin → คำถามเกม'; + } + if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } + draw(); + } else if (gt === 'stack') { + froggerWrap.style.display = 'none'; + if (hint) { + hint.style.display = 'inline'; + hint.innerHTML = 'Stack — กดสลับจังหวะให้บล็อกลงซ้อน · ไม่ใช้แถวกบ · วาดกำแพง/โซน + รูปพื้นหลัง · เล่นจริงยังทำต่อในเครื่องเล่น'; + } + if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } + draw(); + } else { + froggerWrap.style.display = 'none'; + if (hint) { hint.style.display = 'inline'; hint.innerHTML = 'กบข้ามถนน = ตารางแถว · พรมแดงสุดท้าย = ไม่มีตารางแถว · สลับลงตึก = Stack · ห้องโถง = ฉากรอผู้เล่น · ตอบคำถาม = โซนถูก/ผิด + คำถาม · พื้นที่สุ่มจุดเกิด = สุ่มยืนในช่องฟ้าอ่อน'; } + if (editorFroggerAnimationId != null) { cancelAnimationFrame(editorFroggerAnimationId); editorFroggerAnimationId = null; } + draw(); + } + const sgOpt = document.getElementById('draw-mode-option-start-game'); + if (sgOpt) { + const show = gt === 'lobby'; + sgOpt.hidden = !show; + if (!show && drawModeEl && drawModeEl.value === 'startGame') drawModeEl.value = 'wall'; + } + const saOpt = document.getElementById('draw-mode-option-spawn-area'); + if (saOpt) { + const showSa = gt !== 'frogger'; + saOpt.hidden = !showSa; + if (!showSa && drawModeEl && drawModeEl.value === 'spawnArea') drawModeEl.value = 'wall'; + } + const gSpOpt = document.getElementById('draw-mode-option-gauntlet-player-spawn'); + const btnClrG = document.getElementById('btn-clear-gauntlet-spawns'); + if (gSpOpt) { + const showG = gt === 'gauntlet'; + gSpOpt.hidden = !showG; + if (!showG && drawModeEl && drawModeEl.value === 'gauntletPlayerSpawn') drawModeEl.value = 'wall'; + } + if (btnClrG) btnClrG.hidden = gt !== 'gauntlet'; + if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse' || drawModeEl.value === 'quizQuestion') && gt !== 'quiz') { + drawModeEl.value = 'wall'; + } + } + + function initGrid() { + width = Math.max(10, Math.min(50, parseInt(mapW.value, 10) || 20)); + height = Math.max(10, Math.min(40, parseInt(mapH.value, 10) || 15)); + tileSize = Math.max(16, Math.min(64, parseInt(tileSizeEl.value, 10) || 32)); + ground = Array(height).fill(0).map(() => Array(width).fill(0)); + objects = Array(height).fill(0).map(() => Array(width).fill(0)); + blockPlayer = Array(height).fill(0).map(() => Array(width).fill(0)); + interactive = Array(height).fill(0).map(() => Array(width).fill(0)); + startGameArea = Array(height).fill(0).map(() => Array(width).fill(0)); + spawnArea = Array(height).fill(0).map(() => Array(width).fill(0)); + quizTrueArea = Array(height).fill(0).map(() => Array(width).fill(0)); + quizFalseArea = Array(height).fill(0).map(() => Array(width).fill(0)); + quizQuestionArea = Array(height).fill(0).map(() => Array(width).fill(0)); + cellColors = Array(height).fill(0).map(() => Array(width).fill(null)); + for (let i = 0; i < width; i++) { objects[0][i] = 1; objects[height - 1][i] = 1; } + for (let i = 0; i < height; i++) { objects[i][0] = 1; objects[i][width - 1] = 1; } + spawn = { x: 1, y: 1 }; + if (gameType === 'frogger') { lanes = buildDefaultLanes(); ensureLanes(); } + if (gameType === 'gauntlet' || gameType === 'stack') lanes = []; + gauntletPlayerSpawns = []; + characterCells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : 1; + mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize; + if (characterCellsEl) characterCellsEl.value = characterCells; + resize(); draw(); + toggleFroggerUI(); + } + + function resize() { + canvas.width = width * tileSize; + canvas.height = height * tileSize; + sanitizeGauntletPlayerSpawns(); + draw(); + } + + function getCell(e) { + const r = canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - r.left) / tileSize); + const y = Math.floor((e.clientY - r.top) / tileSize); + return { x: Math.max(0, Math.min(width - 1, x)), y: Math.max(0, Math.min(height - 1, y)) }; + } + + function draw() { + const gtDraw = gameTypeEl ? gameTypeEl.value : gameType; + const isFrogger = gtDraw === 'frogger'; + const isGauntlet = gtDraw === 'gauntlet'; + const isStack = gtDraw === 'stack'; + const usesLanes = isFrogger; + if (usesLanes) ensureLanes(); + ensureInteractive(); + ensureStartGameArea(); + ensureSpawnArea(); + if (gtDraw === 'quiz') ensureQuizAreas(); + if (backgroundImageImg && backgroundImageImg.complete && backgroundImageImg.naturalWidth) { + ctx.drawImage(backgroundImageImg, 0, 0, canvas.width, canvas.height); + } else { + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + for (let y = 0; y < height; y++) { + var rowFill = null; + if (usesLanes && lanes[y]) { + if (lanes[y].type === 'goal') rowFill = 'rgba(158,206,106,0.35)'; + else if (lanes[y].type === 'spawn') rowFill = 'rgba(187,154,247,0.35)'; + else if (lanes[y].type === 'road') rowFill = 'rgba(120,100,80,0.5)'; + else if (lanes[y].type === 'water') rowFill = 'rgba(125,207,255,0.4)'; + } else if (isGauntlet) { + rowFill = (y % 2 === 0) ? 'rgba(247,118,190,0.1)' : 'rgba(160,80,120,0.08)'; + } else if (isStack) { + rowFill = (y % 2 === 0) ? 'rgba(122, 162, 247, 0.09)' : 'rgba(187, 154, 247, 0.07)'; + } + for (let x = 0; x < width; x++) { + const tx = x * tileSize, ty = y * tileSize; + if (objects[y][x] === 1) { + ctx.fillStyle = 'rgba(65,72,104,0.9)'; + ctx.fillRect(tx, ty, tileSize, tileSize); + ctx.strokeStyle = '#565f89'; + ctx.strokeRect(tx, ty, tileSize, tileSize); + } else { + const cellColor = cellColors[y] && cellColors[y][x]; + if (cellColor) { ctx.fillStyle = cellColor; ctx.fillRect(tx, ty, tileSize, tileSize); } + else if (rowFill) { ctx.fillStyle = rowFill; ctx.fillRect(tx, ty, tileSize, tileSize); } + else if (!backgroundImageImg || !backgroundImageImg.complete) { + ctx.fillStyle = (x + y) % 2 === 0 ? '#24283b' : '#1f2335'; + ctx.fillRect(tx, ty, tileSize, tileSize); + } + } + if (blockPlayer[y] && blockPlayer[y][x] === 1) { + ctx.fillStyle = 'rgba(255,180,80,0.5)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255,180,80,0.9)'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + } + if (interactive[y] && interactive[y][x] === 1) { + ctx.fillStyle = 'rgba(158,206,106,0.45)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = '#9ece6a'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + } + if (startGameArea[y] && startGameArea[y][x] === 1) { + ctx.fillStyle = 'rgba(255, 158, 100, 0.5)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 120, 60, 0.95)'; + ctx.lineWidth = 2; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + if (spawnArea[y] && spawnArea[y][x] === 1) { + ctx.fillStyle = 'rgba(122, 162, 247, 0.38)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 162, 247, 0.92)'; + ctx.lineWidth = 2; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.lineWidth = 1; + } + if (gtDraw === 'quiz') { + if (quizQuestionArea[y] && quizQuestionArea[y][x] === 1) { + ctx.fillStyle = 'rgba(255, 214, 102, 0.42)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(224, 185, 70, 0.95)'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + } + if (quizTrueArea[y] && quizTrueArea[y][x] === 1) { + ctx.fillStyle = 'rgba(86, 202, 255, 0.5)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(122, 220, 255, 0.95)'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + } + if (quizFalseArea[y] && quizFalseArea[y][x] === 1) { + ctx.fillStyle = 'rgba(247, 118, 190, 0.5)'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + ctx.strokeStyle = 'rgba(255, 130, 200, 0.95)'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, tileSize - 4); + } + } + if (spawn.x === x && spawn.y === y) { + const cells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : characterCells; + const cx = tx + tileSize / 2; + const cy = ty + tileSize / 2; + const size = tileSize * cells; + ctx.fillStyle = 'rgba(158,206,106,0.35)'; + ctx.fillRect(cx - size / 2, cy - size / 2, size, size); + ctx.strokeStyle = '#9ece6a'; + ctx.lineWidth = 2; + ctx.strokeRect(cx - size / 2, cy - size / 2, size, size); + ctx.fillStyle = 'rgba(158,206,106,0.7)'; + ctx.beginPath(); + ctx.arc(cx, cy, Math.min(tileSize / 3, size / 4), 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#9ece6a'; + ctx.stroke(); + } + } + } + if (isFrogger && lanes.length) { + const timeMs = Date.now(); + for (let y = 0; y < height; y++) { + const lane = lanes.find(l => l.y === y); + if (!lane || (lane.type !== 'road' && lane.type !== 'water')) continue; + const positions = getVehiclePositionsEditor(lane, timeMs); + const isRoad = lane.type === 'road'; + for (let i = 0; i < positions.length; i++) { + const vx = positions[i]; + const tx = vx * tileSize, ty = y * tileSize; + ctx.fillStyle = isRoad ? '#e0a060' : '#8b7355'; + ctx.fillRect(tx + 2, ty + 2, tileSize - 4, (tileSize - 4) * 0.7); + ctx.strokeStyle = isRoad ? '#c0caf5' : '#9ece6a'; + ctx.strokeRect(tx + 2, ty + 2, tileSize - 4, (tileSize - 4) * 0.7); + } + } + ctx.fillStyle = '#7aa2f7'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดกบข้ามถนน (จำลองรถ/ท่อนซุง)', 8, 20); + } + if (isGauntlet) { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(gauntletPlayerSpawns.length + ? 'พรมแดง — ลำดับ 1–6 จากโหมด «ลำดับเกิด» (คลิกซ้ายตามลำดับ)' + : 'พรมแดง — ช่องฟ้า = ลำดับ 1→6 อัตโนมัติ (y บน→ล่าง) หรือใช้โหมดลำดับเกิดกำหนดเอง', 8, 20); + const drawGNum = (s, num) => { + const cx = s.x * tileSize + tileSize / 2; + const cy = s.y * tileSize + tileSize / 2 + 4; + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.beginPath(); + ctx.arc(cx, cy - 3, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(String(num), cx, cy + 2); + ctx.textAlign = 'left'; + }; + if (gauntletPlayerSpawns.length) { + for (let i = 0; i < gauntletPlayerSpawns.length; i++) { + const s = gauntletPlayerSpawns[i]; + if (!s || s.x < 0 || s.x >= width || s.y < 0 || s.y >= height) continue; + if (objects[s.y][s.x] === 1) continue; + drawGNum(s, i + 1); + } + } else { + ensureSpawnArea(); + const gsSlots = []; + for (let yy = 0; yy < height; yy++) { + for (let xx = 0; xx < width; xx++) { + if (spawnArea[yy] && spawnArea[yy][xx] === 1 && objects[yy][xx] !== 1) gsSlots.push({ x: xx, y: yy }); + } + } + gsSlots.sort((a, b) => a.y - b.y || a.x - b.x); + for (let i = 0; i < Math.min(6, gsSlots.length); i++) drawGNum(gsSlots[i], i + 1); + } + } + if (gtDraw === 'lobby') { + ctx.fillStyle = '#bb9af7'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดห้องโถง (ฉากรอผู้เล่น)', 8, 20); + } + if (gtDraw === 'quiz') { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดตอบคำถาม (ถูก/ผิด)', 8, 20); + } + if (isStack) { + ctx.fillStyle = '#7dcfff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดสลับจังหวะลงตึก (Stack) — backdrop ฉาก · เกมซ้อนบล็อกยังไม่เปิดใน play', 8, 20); + } + const cells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : characterCells; + ctx.fillStyle = '#c0caf5'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('ขนาดตัวละคร: ' + cells + ' ช่อง', 8, canvas.height - 8); + } + + function editorFroggerTick() { + const gt = gameTypeEl ? gameTypeEl.value : ''; + if (gt !== 'frogger') return; + draw(); + editorFroggerAnimationId = requestAnimationFrame(editorFroggerTick); + } + + function getCellColorWithAlpha() { + const colorEl = document.getElementById('cell-color'); + const alphaEl = document.getElementById('cell-alpha'); + if (!colorEl) return null; + const hex = colorEl.value; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const a = alphaEl ? parseInt(alphaEl.value, 10) / 100 : 1; + return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; + } + + function setCell(x, y, left) { + if (drawModeEl.value === 'gauntletPlayerSpawn') { + const gt = gameTypeEl ? gameTypeEl.value : gameType; + if (gt !== 'gauntlet') return; + if (objects[y][x] === 1) { + if (statusEl) statusEl.textContent = 'ช่องกำแพง — ใช้เป็นจุดเกิดไม่ได้'; + return; + } + if (left) { + if (gauntletPlayerSpawns.some((s) => s.x === x && s.y === y)) return; + if (gauntletPlayerSpawns.length >= 6) { + if (statusEl) statusEl.textContent = 'ครบ 6 จุดแล้ว — คลิกขวาที่ช่องเพื่อถอด หรือปุ่มล้างลำดับ'; + return; + } + gauntletPlayerSpawns.push({ x, y }); + if (statusEl) statusEl.textContent = 'พรมแดง: ผู้เล่น ' + gauntletPlayerSpawns.length + ' = ช่อง (' + x + ',' + y + ')'; + } else { + const before = gauntletPlayerSpawns.length; + gauntletPlayerSpawns = gauntletPlayerSpawns.filter((s) => !(s.x === x && s.y === y)); + if (statusEl) { + statusEl.textContent = before > gauntletPlayerSpawns.length ? 'ถอดจุดเกิดที่ช่องนี้แล้ว' : 'ไม่มีจุดเกิดที่ช่องนี้'; + } + } + return; + } + if (drawModeEl.value === 'wall') { + objects[y][x] = left ? 1 : 0; + } else if (drawModeEl.value === 'interactive') { + if (!interactive[y]) interactive[y] = Array(width).fill(0); + interactive[y][x] = left ? 1 : 0; + } else if (drawModeEl.value === 'spawnArea') { + const gt = gameTypeEl ? gameTypeEl.value : gameType; + if (gt === 'frogger') return; + ensureSpawnArea(); + spawnArea[y][x] = left ? 1 : 0; + } else if (drawModeEl.value === 'startGame') { + const gt = gameTypeEl ? gameTypeEl.value : gameType; + if (gt !== 'lobby') return; + ensureStartGameArea(); + startGameArea[y][x] = left ? 1 : 0; + } else if (drawModeEl.value === 'quizTrue') { + if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; + ensureQuizAreas(); + quizTrueArea[y][x] = left ? 1 : 0; + if (left) quizFalseArea[y][x] = 0; + } else if (drawModeEl.value === 'quizFalse') { + if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; + ensureQuizAreas(); + quizFalseArea[y][x] = left ? 1 : 0; + if (left) quizTrueArea[y][x] = 0; + } else if (drawModeEl.value === 'quizQuestion') { + if ((gameTypeEl ? gameTypeEl.value : gameType) !== 'quiz') return; + ensureQuizAreas(); + quizQuestionArea[y][x] = left ? 1 : 0; + } else if (drawModeEl.value === 'color') { + if (!cellColors[y]) cellColors[y] = Array(width).fill(null); + cellColors[y][x] = left ? getCellColorWithAlpha() : null; + } else { + if (!blockPlayer[y]) blockPlayer[y] = Array(width).fill(0); + blockPlayer[y][x] = left ? 1 : 0; + } + } + + canvas.addEventListener('mousedown', (e) => { + const { x, y } = getCell(e); + if (isSpawnMode) { + if (objects[y][x] === 0) { spawn = { x, y }; draw(); statusEl.textContent = 'จุดเกิด: ' + x + ',' + y; } + isSpawnMode = false; + document.getElementById('btn-spawn').textContent = 'ตั้งจุดเกิด'; + return; + } + setCell(x, y, e.button === 0); + draw(); + }); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + canvas.addEventListener('mousemove', (e) => { + if (e.buttons !== 1 && e.buttons !== 2) return; + const { x, y } = getCell(e); + setCell(x, y, e.buttons === 1); + draw(); + }); + + if (mapLoadEl) { + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + mapLoadEl.innerHTML = ''; + (list || []).forEach(m => { + const opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = (m.name || m.id) + ' (' + m.id + ')'; + mapLoadEl.appendChild(opt); + }); + if (mapId) mapLoadEl.value = mapId; + }) + .catch(() => { mapLoadEl.innerHTML = ''; }); + document.getElementById('btn-load').addEventListener('click', () => { + const id = mapLoadEl && mapLoadEl.value; + if (id) window.location.href = 'editor.html' + buildEditorSearch(id); + }); + } + + const bgInput = document.getElementById('bg-image'); + if (bgInput) { + bgInput.addEventListener('change', (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const r = new FileReader(); + r.onload = () => { + backgroundImage = r.result; + backgroundImageImg = new Image(); + backgroundImageImg.onload = () => { draw(); }; + backgroundImageImg.src = backgroundImage; + }; + r.readAsDataURL(file); + e.target.value = ''; + }); + } + document.getElementById('btn-clear-bg').addEventListener('click', () => { + backgroundImage = null; + backgroundImageImg = null; + draw(); + }); + + document.getElementById('btn-new').addEventListener('click', initGrid); + document.getElementById('btn-spawn').addEventListener('click', () => { + isSpawnMode = true; + document.getElementById('btn-spawn').textContent = 'คลิกช่องที่ต้องการ'; + }); + if (gameTypeEl) gameTypeEl.addEventListener('change', () => { + gameType = gameTypeEl.value; + if (drawModeEl && drawModeEl.value === 'startGame' && gameType !== 'lobby') drawModeEl.value = 'wall'; + if (drawModeEl && drawModeEl.value === 'spawnArea' && gameType === 'frogger') drawModeEl.value = 'wall'; + if (drawModeEl && (drawModeEl.value === 'quizTrue' || drawModeEl.value === 'quizFalse' || drawModeEl.value === 'quizQuestion') && gameType !== 'quiz') drawModeEl.value = 'wall'; + if (drawModeEl && drawModeEl.value === 'gauntletPlayerSpawn' && gameType !== 'gauntlet') drawModeEl.value = 'wall'; + toggleFroggerUI(); + }); + + const btnClearGauntletSpawns = document.getElementById('btn-clear-gauntlet-spawns'); + if (btnClearGauntletSpawns) { + btnClearGauntletSpawns.addEventListener('click', () => { + gauntletPlayerSpawns = []; + if (statusEl) statusEl.textContent = 'ล้างลำดับเกิดพรมแดง (1–6) แล้ว'; + draw(); + }); + } + + function openPlayTestMap() { + const id = mapId && String(mapId).trim(); + if (!id) { + if (statusEl) statusEl.textContent = 'บันทึกฉากก่อน จึงจะทดลองเล่นได้ (ต้องมีรหัสฉากใน URL)'; + try { alert('บันทึกฉากก่อน — ปุ่มทดลองเล่นต้องใช้รหัสฉากหลังบันทึก (แก้จากฉากเดิมแล้วกดบันทึก)'); } catch (e) { /* ignore */ } + return; + } + const nick = 'ทดสอบ' + Math.floor(Math.random() * 9000 + 1000); + if (statusEl) statusEl.textContent = 'กำลังสร้างห้องทดสอบ…'; + var maxPl = (gameTypeEl && gameTypeEl.value === 'gauntlet') ? 6 : 4; + fetch(SERVER + '/api/spaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mapId: id, isPrivate: true, name: 'preview', maxPlayers: maxPl }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data || !data.ok) { + if (statusEl) statusEl.textContent = (data && data.error) || 'สร้างห้องทดสอบไม่ได้'; + return; + } + var sid = data.spaceId; + var q = 'play.html?space=' + encodeURIComponent(sid) + + '&nick=' + encodeURIComponent(nick) + + '&map=' + encodeURIComponent(id) + + '&preview=1&defaultChar=1' + + (editorEmbed ? '&editorEmbed=1' : ''); + if (statusEl) statusEl.textContent = 'เปิดหน้าทดสอบเล่นในแท็บใหม่แล้ว — ปิดแท็บเมื่อเล่นเสร็จ'; + window.open(BASE + '/' + q, '_blank', 'noopener,noreferrer'); + }) + .catch(function () { + if (statusEl) statusEl.textContent = 'สร้างห้องทดสอบไม่ได้'; + }); + } + + try { window.gameEditorOpenPlayTest = openPlayTestMap; } catch (e) { /* ignore */ } + + var btnPlayTest = document.getElementById('btn-play-test'); + if (btnPlayTest) btnPlayTest.addEventListener('click', openPlayTestMap); + + document.getElementById('btn-save').addEventListener('click', () => { + const name = (mapName.value || '').trim() || 'ฉากใหม่'; + gameType = gameTypeEl ? (gameTypeEl.value || 'zep') : 'zep'; + if (gameType === 'frogger') ensureLanes(); + ensureInteractive(); + ensureStartGameArea(); + ensureSpawnArea(); + ensureQuizAreas(); + characterCells = characterCellsEl ? Math.max(1, Math.min(4, parseInt(characterCellsEl.value, 10) || 1)) : 1; + const showMapInGameEl = document.getElementById('show-map-in-game'); + showMapInGame = showMapInGameEl ? showMapInGameEl.checked : true; + const body = { + name, gameType, width, height, tileSize, characterCells, ground, objects, blockPlayer, interactive, startGameArea, spawnArea, spawn, cellColors, showMapInGame, + quizTrueArea, quizFalseArea, quizQuestionArea, + }; + if (backgroundImage) body.backgroundImage = backgroundImage; + body.lanes = gameType === 'frogger' ? lanes : []; + sanitizeGauntletPlayerSpawns(); + body.gauntletPlayerSpawns = gameType === 'gauntlet' + ? gauntletPlayerSpawns.map((s) => ({ x: s.x, y: s.y })) + : []; + const url = mapId ? SERVER + '/api/maps/' + mapId : SERVER + '/api/maps'; + const method = mapId ? 'PUT' : 'POST'; + fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + const id = data.mapId || mapId; + statusEl.textContent = 'บันทึกแล้ว รหัสฉาก: ' + id + (showMapInGame ? '' : ' (ไม่โชว์ฉาก) — ออกจากห้องแล้วเข้าใหม่ หรือกด F5 ในหน้าเล่น ถึงจะเห็นผล'); + if (!mapId) window.history.replaceState({}, '', 'editor.html' + buildEditorSearch(id)); + } else statusEl.textContent = data.error || 'บันทึกไม่ได้'; + }) + .catch(() => { statusEl.textContent = 'บันทึกไม่ได้'; }); + }); + + const cellColorWrap = document.getElementById('cell-color-wrap'); + const cellAlphaEl = document.getElementById('cell-alpha'); + const cellAlphaValue = document.getElementById('cell-alpha-value'); + if (drawModeEl) drawModeEl.addEventListener('change', () => { + if (cellColorWrap) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none'; + }); + if (cellColorWrap && drawModeEl) cellColorWrap.style.display = drawModeEl.value === 'color' ? 'inline' : 'none'; + if (cellAlphaEl && cellAlphaValue) cellAlphaEl.addEventListener('input', () => { cellAlphaValue.textContent = cellAlphaEl.value; draw(); }); + + mapW.addEventListener('change', resize); + mapH.addEventListener('change', resize); + tileSizeEl.addEventListener('change', resize); + if (characterCellsEl) characterCellsEl.addEventListener('input', draw); + + if (mapId) { + fetch(SERVER + '/api/maps/' + mapId) + .then(r => r.json()) + .then(m => { + width = m.width; height = m.height; tileSize = m.tileSize || 32; characterCells = Math.max(1, Math.min(4, m.characterCells || 1)); + ground = m.ground || []; + objects = m.objects || []; + blockPlayer = m.blockPlayer ? m.blockPlayer.map(r => r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + interactive = m.interactive && m.interactive.length ? m.interactive.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + startGameArea = m.startGameArea && m.startGameArea.length ? m.startGameArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + spawnArea = m.spawnArea && m.spawnArea.length ? m.spawnArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + quizTrueArea = m.quizTrueArea && m.quizTrueArea.length ? m.quizTrueArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + quizFalseArea = m.quizFalseArea && m.quizFalseArea.length ? m.quizFalseArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + quizQuestionArea = m.quizQuestionArea && m.quizQuestionArea.length ? m.quizQuestionArea.map(r => r && r.slice()) : Array(height).fill(0).map(() => Array(width).fill(0)); + cellColors = (m.cellColors && m.cellColors.length === height) ? m.cellColors.map(r => r && r.slice ? r.slice() : Array(width).fill(null)) : Array(height).fill(0).map(() => Array(width).fill(null)); + showMapInGame = m.showMapInGame !== false; + const showMapEl = document.getElementById('show-map-in-game'); + if (showMapEl) showMapEl.checked = showMapInGame; + ensureInteractive(); + ensureStartGameArea(); + ensureSpawnArea(); + backgroundImage = m.backgroundImage || null; + if (backgroundImage) { + backgroundImageImg = new Image(); + backgroundImageImg.onload = () => { draw(); }; + backgroundImageImg.src = backgroundImage; + } else backgroundImageImg = null; + spawn = m.spawn || { x: 1, y: 1 }; + gauntletPlayerSpawns = Array.isArray(m.gauntletPlayerSpawns) + ? m.gauntletPlayerSpawns.map((s) => ({ x: Math.floor(Number(s.x)), y: Math.floor(Number(s.y)) })) + .filter((s) => Number.isFinite(s.x) && Number.isFinite(s.y)) + : []; + gameType = m.gameType || 'zep'; + if (gameTypeEl) gameTypeEl.value = gameType; + lanes = (m.lanes && m.lanes.length) ? m.lanes.map(l => ({ ...l })) : []; + if (gameType === 'gauntlet' || gameType === 'stack') lanes = []; + if (gameType === 'frogger') { + ensureLanes(); + if (lanes.length !== height) ensureLanes(); + } + mapName.value = m.name || ''; mapW.value = width; mapH.value = height; tileSizeEl.value = tileSize; + if (characterCellsEl) characterCellsEl.value = characterCells; + sanitizeGauntletPlayerSpawns(); + resize(); draw(); + toggleFroggerUI(); + }) + .catch(() => initGrid()); + } else { initGrid(); toggleFroggerUI(); } +})(); diff --git a/www/html/Game/public/TryChangeCSS/js/lobby.js b/www/html/Game/public/TryChangeCSS/js/lobby.js new file mode 100644 index 0000000..b37497e --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/lobby.js @@ -0,0 +1,101 @@ +(function () { + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const roomListEl = document.getElementById('room-list'); + const roomListStatus = document.getElementById('room-list-status'); + const LOBBY_MAP_NAME = 'LobbyA'; + const LOBBY_ROOM_NAME = 'LobbyA'; + + function getNick() { + const el = document.getElementById('nick'); + return (el && el.value || '').trim() || 'ผู้เล่น'; + } + + function refreshRoomList() { + if (!roomListStatus) return; + roomListStatus.textContent = 'โหลดรายการห้อง...'; + fetch(SERVER + '/api/spaces') + .then(r => r.json()) + .then(list => { + if (!Array.isArray(list)) { roomListStatus.textContent = 'ไม่มีห้องที่เปิดอยู่'; return; } + roomListStatus.textContent = ''; + if (!roomListEl) return; + roomListEl.innerHTML = ''; + if (list.length === 0) { + roomListStatus.textContent = 'ยังไม่มีห้องสาธารณะ — สร้างห้อง Public หรือเข้าร่วมห้อง Private ด้วยรหัสด้านล่าง'; + return; + } + list.forEach(room => { + const li = document.createElement('li'); + li.className = 'room-item'; + const code = room.spaceId || ('room-' + Math.random().toString(36).slice(2, 8)); + const mapName = room.mapName || '—'; + const count = room.peerCount != null ? room.peerCount : 0; + const max = room.maxPlayers != null ? room.maxPlayers : 10; + const full = count >= max; + const namePart = room.spaceName ? ' (' + escapeHtml(room.spaceName) + ')' : ''; + li.innerHTML = '' + escapeHtml(code) + '' + namePart + ' ฉาก: ' + escapeHtml(mapName) + ' · ' + count + '/' + max + ' คน' + (full ? ' (เต็ม)' : '') + ' '; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = full ? 'เต็ม' : 'เข้าร่วม'; + btn.disabled = full; + btn.addEventListener('click', () => { + window.location.href = 'room-lobby.html?space=' + encodeURIComponent(room.spaceId) + '&nick=' + encodeURIComponent(getNick()); + }); + li.appendChild(btn); + roomListEl.appendChild(li); + }); + }) + .catch(() => { + if (roomListStatus) roomListStatus.textContent = 'โหลดรายการห้องไม่ได้'; + }); + } + + function escapeHtml(s) { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; + } + + if (roomListEl) refreshRoomList(); + setInterval(refreshRoomList, 8000); + + const btnCreateLobby = document.getElementById('btn-create-lobby'); + if (btnCreateLobby) { + btnCreateLobby.addEventListener('click', () => { + const nick = getNick(); + fetch(SERVER + '/api/maps') + .then(r => r.json()) + .then(list => { + const maps = Array.isArray(list) ? list : []; + const lobbyMap = maps.find(m => (m.name || '').trim().toLowerCase() === LOBBY_MAP_NAME.toLowerCase()); + const mapId = lobbyMap ? lobbyMap.id : 'lobbya'; + const isPrivate = document.querySelector('input[name="room-type"]:checked')?.value === 'private'; + const maxPlayers = Math.min(10, Math.max(2, parseInt(document.getElementById('max-players')?.value || 10, 10))); + const roomName = isPrivate ? 'ห้องส่วนตัว' : LOBBY_ROOM_NAME; + return fetch(SERVER + '/api/spaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mapId, name: roomName, isPrivate, maxPlayers }) + }).then(r => r.json()).then(data => { + if (data.ok) { + window.location.href = 'room-lobby.html?space=' + encodeURIComponent(data.spaceId) + '&nick=' + encodeURIComponent(nick); + } else { + alert(data.error || 'สร้างห้องไม่ได้'); + } + }); + }) + .catch(() => alert('โหลดรายการฉากไม่ได้ หรือยังไม่มีฉากชื่อ "' + LOBBY_MAP_NAME + '" ใน Editor — สร้างฉากชื่อ LobbyA ก่อน')); + }); + } + + const formJoin = document.getElementById('form-join'); + const spaceIdInput = document.getElementById('space-id'); + if (formJoin && spaceIdInput) { + formJoin.addEventListener('submit', (e) => { + e.preventDefault(); + const spaceId = spaceIdInput.value.trim(); + window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(getNick()); + }); + } +})(); diff --git a/www/html/Game/public/TryChangeCSS/js/play.js b/www/html/Game/public/TryChangeCSS/js/play.js new file mode 100644 index 0000000..edbd6dc --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/play.js @@ -0,0 +1,2808 @@ +(function () { + const BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game'; + const SERVER = (typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : '') + '/Game'; + const params = new URLSearchParams(window.location.search); + const spaceId = params.get('space'); + const nick = params.get('nick') || 'ผู้เล่น'; + const previewMode = params.get('preview') === '1'; + const forceDefaultCharacter = params.get('defaultChar') === '1'; + const editorEmbedReturn = params.get('editorEmbed') === '1'; + const lobbyLevelParam = params.get('lobbyLevel'); + const lobbyCaseParam = params.get('case'); + if (lobbyLevelParam || lobbyCaseParam) { + try { + window.__detectiveLobbyMeta = { level: lobbyLevelParam, caseId: lobbyCaseParam }; + } catch (e) { /* ignore */ } + } + /** ทดสอบจากเอดิเตอร์: เติมบอทให้ครบจำนวน (รวมผู้เล่นจริง) — ?fillTotal=6 (ค่าเริ่ม 6) */ + const previewFillBots = previewMode && editorEmbedReturn; + const previewTargetHeadcount = Math.min(24, Math.max(1, parseInt(params.get('fillTotal'), 10) || 6)); + const PREVIEW_BOT_PREFIX = '__pv_bot_'; + let previewBotSeq = 0; + if (!spaceId) { window.location.replace(BASE + '/lobby.html'); return; } + + const socket = io(typeof GAME_SERVER !== 'undefined' ? GAME_SERVER : undefined, { path: '/Game/socket.io' }); + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + let mapData = null, tileSize = 32, myId = null; + let mapBackgroundImg = null; + let me = { x: 1, y: 1, direction: 'down', nickname: nick, isWalking: false, playTint: null, gauntletScore: 0, tx: null, ty: null }; + const others = new Map(); + const keys = {}; + const characterImages = {}; + function createDefaultAvatarImg() { + const c = document.createElement('canvas'); + c.width = 64; c.height = 64; + const ctx = c.getContext('2d'); + ctx.fillStyle = '#7aa2f7'; + ctx.beginPath(); + ctx.arc(32, 22, 14, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#9ece6a'; + ctx.beginPath(); + ctx.arc(32, 48, 18, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#1a1b26'; + ctx.beginPath(); + ctx.arc(28, 20, 3, 0, Math.PI * 2); + ctx.arc(36, 20, 3, 0, Math.PI * 2); + ctx.fill(); + const img = new Image(); + img.src = c.toDataURL('image/png'); + return img; + } + const defaultAvatarImg = createDefaultAvatarImg(); + const characterAnimations = {}; + const 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; + } + + /** สุ่มสีตามเลเยอร์เดียวกับ 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 }; + + function ensurePlayCharLayerListFetch() { + if (playCharLayerApi.status !== 'idle') return; + playCharLayerApi.status = 'loading'; + fetch(SERVER + '/api/characters') + .then((r) => r.json()) + .then((list) => { + const map = Object.create(null); + if (Array.isArray(list)) { + list.forEach((c) => { + if (c && c.id) map[c.id] = !!c.hasLayerFiles; + }); + } + playCharLayerApi = { status: 'done', map }; + }) + .catch(() => { + playCharLayerApi = { status: 'done', map: Object.create(null) }; + }); + } + + /** @returns {boolean|null} true/false จาก API, null = ยังไม่โหลดหรือไม่มีรายการ id นี้ */ + function playCharLayerFromApi(characterId) { + if (playCharLayerApi.status !== 'done' || !characterId) return null; + if (Object.prototype.hasOwnProperty.call(playCharLayerApi.map, characterId)) { + return playCharLayerApi.map[characterId]; + } + return null; + } + + 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)], + }; + } + + function playTintFromPeerId(id) { + let h = 0; + const s = id || ''; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return { + head: PLAY_TINT_HEAD[h % PLAY_TINT_HEAD.length], + hair: PLAY_TINT_HAIR[(h >>> 3) % PLAY_TINT_HAIR.length], + body: PLAY_TINT_BODY[(h >>> 7) % 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 = SERVER + '/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; + } + + function shadowUrlCandidates(id, dir, frameIndex) { + const c = layerUrlCandidates(id, dir, 'shadow', frameIndex); + const def = SERVER + '/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) { + 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; + for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { + const layerName = PLAY_LAYER_ORDER[li]; + const urls = layerName === 'shadow' + ? shadowUrlCandidates(id, dir, frameIndex) + : layerUrlCandidates(id, dir, layerName, frameIndex); + 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 frameIndex = getCharacterAnimFrameIndex(characterId, dir, timeMs, isWalking); + + if (playCharLayerDiscoveryPending(characterId)) return rawImg; + + const apiFlag = playCharLayerFromApi(characterId); + const charHasLayerFiles = apiFlag === true || playCharAnyDirectionLayered(characterId); + const cacheKey = [characterId, dir, frameIndex, tint.head, tint.hair, tint.body].join('|'); + const hit = playLayerCompositeCache.get(cacheKey); + if (hit) return hit; + + /* มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (API) หรือ probe เจอ — ลองประกอบเลเยอร์เสมอ แม้ probe ทิศนี้ยัง none */ + if (charHasLayerFiles) { + const pack = tryComposePlayLayersFromFiles(rawImg, characterId, dir, frameIndex, tint); + if (pack && pack.canvas) { + if (!pack.skipCache) playLayerCompositeCache.set(cacheKey, pack.canvas); + return pack.canvas; + } + return rawImg; + } + + /* + * ไม่มีไฟล์ *_layer_* บนเซิร์ฟเวอร์ (เช็คจาก API) → แสดง PNG รวมตามดีไซน์จริง + * ไม่ใช้แถบแนวตั้ง — มันดูเหมือนสีย้อมเลเยอร์แต่ไม่ใช่ ทำให้เข้าใจผิด + */ + 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 = SERVER + '/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 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 = SERVER + '/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; + } + const DEFAULT_CHARACTER_ID = 'Chatest'; + function getStoredCharacterId() { + try { return localStorage.getItem('gameCharacterId') || DEFAULT_CHARACTER_ID; } catch (e) { return DEFAULT_CHARACTER_ID; } + } + function getPlayCharacterId() { + if (forceDefaultCharacter) return DEFAULT_CHARACTER_ID; + return getStoredCharacterId(); + } + const MOVE_SPEED = 0.15; + const PATH_ARRIVE_THRESH = 0.15; + + function isMovementKey(code) { + return ['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].indexOf(code) !== -1; + } + function isChatFocused() { + return false; + } + let zoom = 1.4; + let froggerScore = 0; + let lastFroggerKey = 0; + let gauntletObstacles = []; + /** อินเทอร์โพเลตการวาด obstacle ระหว่างแพ็กเก็ต sync (~220ms) */ + let gauntletObsRenderPrev = []; + let gauntletObsRenderNext = []; + let gauntletObsBlendT0 = 0; + let meGauntletJumpTicks = 0; + /** ค่าที่ใช้วาดการยกตัว (เลอร์ปจาก meGauntletJumpTicks ให้โค้งกระโดดไม่กระตุก) */ + let meGauntletJumpVis = 0; + /** ซิงก์จากเซิร์ฟเวอร์ (gauntlet-sync / GET /api/game-timing) */ + let gauntletRuntimeTickMs = 220; + let gauntletRuntimeJumpTicks = 16; + /** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync */ + let gauntletRuntimeTimeLimitSec = 0; + /** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */ + let gauntletEndsAtMs = 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 ensureGauntletAssetImage(url) { + const u = typeof url === 'string' ? url.trim() : ''; + if (!u) return null; + let rec = gauntletAssetImageCache.get(u); + if (rec) return rec; + rec = { img: new Image(), ready: false }; + if (/^https?:\/\//i.test(u)) { + try { rec.img.crossOrigin = 'anonymous'; } catch (e) { /* ignore */ } + } + rec.img.onload = function () { rec.ready = true; }; + rec.img.onerror = function () { rec.ready = false; }; + rec.img.src = u; + gauntletAssetImageCache.set(u, rec); + return rec; + } + + function pickGauntletLaneImageRec(obsId) { + if (!gauntletLaneImageUrls.length) return null; + const n = gauntletLaneImageUrls.length; + const idx = Math.abs(Number(obsId) | 0) % n; + const u = gauntletLaneImageUrls[idx]; + return u ? ensureGauntletAssetImage(u) : null; + } + + function clampClientLaserColor(s, def) { + const t = String(s || '').trim().slice(0, 100); + if (!t || /[<>"'`]/.test(t)) return def; + return t; + } + + let playQuizPhaseLocal = null; + let playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + let playQuizText = ''; + let playQuizPhaseEndsAt = 0; + let playQuizTimerInterval = null; + /** Preview-only: real question pool + phased timer (matches server quiz-settings / map quizQuestions). */ + let previewQuizPool = []; + let previewQuizTiming = { readMs: 10000, answerMs: 5000, betweenMs: 3500 }; + let previewQuizStep = 'read'; + let previewQuizCurrent = null; + let playLiveQuizScores = {}; + + 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()) 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); + }); + } + + function initPlayLiveQuizScoresZeros() { + if (!isQuiz() || myId == null) return; + playLiveQuizScores = {}; + playLiveQuizScores[myId] = 0; + others.forEach((_, id) => { playLiveQuizScores[id] = 0; }); + renderPlayQuizScoreboard(playLiveQuizScores); + } + + function showPlayQuizFeedback(r) { + const el = document.getElementById('play-quiz-feedback'); + if (!el || !r || !r.results || myId == null) return; + let mine = null; + let botRight = 0, botWrong = 0; + for (let i = 0; i < r.results.length; i++) { + const row = r.results[i]; + if (row.id === myId) mine = row; + else if (isPreviewBotId(row.id)) { + if (row.right) botRight++; + else botWrong++; + } + } + if (!mine) return; + el.classList.remove('is-hidden'); + const botExtra = previewFillBots && (botRight + botWrong > 0) + ? ' · บอท ถูก ' + botRight + ' / ผิด ' + botWrong + ' (Bots: ' + botRight + ' right / ' + botWrong + ' wrong)' + : ''; + if (mine.right) { + el.className = 'play-quiz-feedback play-quiz-feedback-ok'; + el.textContent = 'คุณตอบถูก · คะแนนรวม ' + (typeof mine.score === 'number' ? mine.score : 0) + ' แต้ม' + botExtra; + } else { + el.className = 'play-quiz-feedback play-quiz-feedback-bad'; + el.textContent = (mine.choice == null + ? 'คุณไม่ได้ยืนในโซนตอบ — นับเป็นผิด' + : 'คุณตอบผิด — กลับจุดเกิด และเข้าโซนตอบไม่ได้อีก') + botExtra; + } + if (typeof window.__playQuizFeedbackT === 'number') clearTimeout(window.__playQuizFeedbackT); + window.__playQuizFeedbackT = setTimeout(() => { el.classList.add('is-hidden'); }, 4200); + } + + function getQuizQuestionAreaTileBounds(md) { + if (!md || md.gameType !== 'quiz') return null; + const grid = md.quizQuestionArea; + 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 }; + } + + function syncPlayQuizMapPanel() { + const panel = document.getElementById('quiz-map-question-panel'); + const textEl = document.getElementById('quiz-map-question-text'); + if (!panel || !textEl || !mapData || !isQuiz()) return; + const bounds = getQuizQuestionAreaTileBounds(mapData); + const text = (playQuizText || '').trim(); + if (!bounds || !text) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + return; + } + const camX = me.x * tileSize; + const camY = me.y * tileSize; + const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom; + textEl.textContent = text; + panel.style.left = Math.round(left) + 'px'; + panel.style.top = Math.round(top) + 'px'; + panel.style.width = Math.round(Math.max(48, wPx)) + 'px'; + panel.style.height = Math.round(Math.max(40, hPx)) + 'px'; + panel.classList.remove('is-hidden'); + panel.setAttribute('aria-hidden', 'false'); + } + + 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 buildQuizPoolFromMap(md) { + if (!md || !Array.isArray(md.quizQuestions)) return []; + return md.quizQuestions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + + function pickRandomQuizFromPool(pool) { + if (!pool || !pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; + } + + function clearPreviewBotAnswerPaths() { + if (!previewFillBots) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function resetPreviewBotsQuizState() { + if (!previewFillBots) return; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.quizCannotTrue = false; + o.quizCannotFalse = false; + o.botPath = []; + o.botAnswerWander = false; + }); + } + + function applyPreviewReadPhase(q, poolLen) { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'read'; + playQuizPhaseLocal = 'read'; + previewQuizCurrent = q; + playQuizText = q.text; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.readMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] อ่านคำถาม · สุ่มจากชุด ' + poolLen + ' ข้อ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนทองบนแผนที่ = ข้อความคำถาม · ฟ้า = จริง · ชมพู = เท็จ'; + } + + function applyPreviewAnswerPhase() { + previewQuizStep = 'answer'; + playQuizPhaseLocal = 'answer'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.answerMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] เดินไปโซน ถูก / ผิด'; + const leg = document.getElementById('quiz-play-legend'); + if (leg && previewQuizCurrent) { + leg.textContent = previewQuizCurrent.answerTrue + ? 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «จริง» — โซนสีฟ้า' + : 'เฉลย (ทดสอบ): คำตอบที่ถูกคือ «เท็จ» — โซนสีชมพู'; + } + previewBotsPrepareAnswerRound(); + } + + function applyPreviewBetweenPhase() { + clearPreviewBotAnswerPaths(); + previewQuizStep = 'between'; + playQuizPhaseLocal = null; + playQuizText = 'ข้อถัดไปกำลังจะมา…'; + playQuizPhaseEndsAt = Date.now() + previewQuizTiming.betweenMs; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] พักระหว่างข้อ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'รอสักครู่แล้วจะสุ่มคำถามใหม่จากชุดเดิม'; + } + + function quizCellOnPlay(grid, x, y) { + return !!(grid && grid[y] && grid[y][x] === 1); + } + + function spawnTileWalkablePlay(md, x, y) { + const w = md.width || 20; + const h = md.height || 15; + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + if (row && row[x] === 1) return false; + return true; + } + + /** เหมือน server pickRandomSpawnFromMap — สุ่มด้วย crypto.getRandomValues */ + function pickRandomSpawnFromMapPlay(md) { + const fallback = md.spawn || { x: 1, y: 1 }; + const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; + const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; + const grid = md.spawnArea; + if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; + const w = md.width || 20; + const h = md.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (Number(row[x]) === 1 && spawnTileWalkablePlay(md, x, y)) pool.push({ x, y }); + } + } + if (!pool.length) return { x: fx, y: fy }; + const u = new Uint32Array(1); + (typeof crypto !== 'undefined' && crypto.getRandomValues) + ? crypto.getRandomValues(u) + : (u[0] = (Math.floor(Math.random() * 0xffffffff) >>> 0)); + const pick = pool[u[0] % pool.length]; + return { x: pick.x, y: pick.y }; + } + + 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 (!previewFillBots || !mapData) return; + const human = countPlayHumans(); + const wantBots = Math.max(0, previewTargetHeadcount - human); + [...others.keys()].filter(isPreviewBotId).forEach((bid) => { + const o = others.get(bid); + if (!o) return; + if (o.botTier == null) { + const tierRoll = Math.random(); + o.botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'; + } + if (o.quizCannotTrue == null) o.quizCannotTrue = false; + if (o.quizCannotFalse == null) o.quizCannotFalse = false; + if (!Array.isArray(o.botPath)) o.botPath = []; + if (o.botAnswerWander == null) o.botAnswerWander = false; + if (o.gauntletJumpTicks == null) o.gauntletJumpTicks = 0; + if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; + if (o.gauntletScore == null) o.gauntletScore = 0; + 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 sp = pickRandomSpawnFromMapPlay(mapData); + const jx = (Math.random() - 0.5) * 0.4; + const jy = (Math.random() - 0.5) * 0.4; + const x = sp.x + 0.5 + jx; + const y = sp.y + 0.5 + jy; + const tierRoll = Math.random(); + const botTier = tierRoll < 0.28 ? 'sharp' : tierRoll < 0.62 ? 'avg' : 'weak'; + const wd = [[0, -1], [0, 1], [-1, 0], [1, 0]][Math.floor(Math.random() * 4)]; + others.set(id, { + x, y, tx: x, ty: y, + direction: ['down', 'up', 'left', 'right'][Math.floor(Math.random() * 4)], + nickname: 'บอท', + characterId: getPlayCharacterId(), + playTint: pickRandomPlayTint(), + botTier, + gauntletJumpTicks: 0, + gauntletJumpVis: 0, + gauntletScore: 0, + quizCannotTrue: false, + quizCannotFalse: false, + botPath: [], + botAnswerWander: false, + botWanderDx: wd[0], + botWanderDy: wd[1], + botWanderNextTurn: Date.now() + 400 + Math.floor(Math.random() * 1000), + }); + botIds.push(id); + } + let k = 0; + [...others.keys()].filter(isPreviewBotId).sort().forEach((bid) => { + const o = others.get(bid); + if (!o) return; + const tag = o.botTier === 'sharp' ? '(ฉลาด)' : o.botTier === 'weak' ? '(พลาดบ่อย)' : '(กลาง)'; + o.nickname = 'บอท ' + (++k) + ' ' + tag; + }); + if (mapData.gameType === 'gauntlet') { + applyGauntletPreviewSpawnLayout(false); + emitGauntletPreviewRowsToServer(); + } + } + + function pickRandomWalkableCellInAnswerGrid(grid, o) { + if (!grid || !mapData) return null; + const w = mapData.width || 20, h = mapData.height || 15; + const pool = []; + for (let y = 0; y < h; y++) { + const row = grid[y]; + if (!row) continue; + for (let x = 0; x < w; x++) { + if (row[x] !== 1) continue; + if (canWalkLikeLobbyForBot(x + 0.5, y + 0.5, NaN, NaN, o)) pool.push({ x, y }); + } + } + if (!pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; + } + + /** ช่วงตอบ: บอทสุ่มเดินไปโซนถูก/ผิด/เดินมั่ว ตามระดับ (ฉลาด/กลาง/พลาดบ่อย) */ + function previewBotsPrepareAnswerRound() { + if (!previewFillBots || !mapData || !isQuiz() || !previewQuizCurrent) return; + const correctTrue = !!previewQuizCurrent.answerTrue; + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + o.botPath = []; + o.botAnswerWander = false; + const tier = o.botTier || 'avg'; + let pCorrect = 0.52; + if (tier === 'sharp') pCorrect = 0.84; + else if (tier === 'weak') pCorrect = 0.22; + if (Math.random() < 0.14) { + o.botAnswerWander = true; + return; + } + const wantsCorrect = Math.random() < pCorrect; + const targetTrue = wantsCorrect ? correctTrue : !correctTrue; + const zoneGrid = targetTrue ? qt : qf; + const dest = pickRandomWalkableCellInAnswerGrid(zoneGrid, o); + if (!dest) { + o.botAnswerWander = true; + return; + } + const path = pathfindPlayForBot(o.x, o.y, dest.x + 0.5, dest.y + 0.5, o); + if (!path || path.length <= 1) { + o.botAnswerWander = true; + return; + } + o.botPath = path.slice(1); + }); + } + + /** คัดลอกตรรกะจาก server — ดีดออกนอกโซนจริง/เท็จ (โหมดทดสอบ preview บน play.html) */ + function findNearestOutsideQuizAnswerZonesPlay(md, sx, sy) { + const w = md.width || 20; + const h = md.height || 15; + const tGrid = md.quizTrueArea || []; + const fGrid = md.quizFalseArea || []; + const inAnswer = (x, y) => quizCellOnPlay(tGrid, x, y) || quizCellOnPlay(fGrid, x, y); + const walkable = (x, y) => { + if (x < 0 || x >= w || y < 0 || y >= h) return false; + const row = md.objects && md.objects[y]; + return row && row[x] !== 1; + }; + const fx = Math.floor(Number(sx)); + const fy = Math.floor(Number(sy)); + if (!walkable(fx, fy)) { + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + if (!inAnswer(fx, fy)) return { x: sx, y: sy }; + const q = [[fx, fy]]; + const seen = new Set([`${fx},${fy}`]); + const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; + while (q.length) { + const [cx, cy] = q.shift(); + for (let di = 0; di < dirs.length; di++) { + const nx = cx + dirs[di][0]; + const ny = cy + dirs[di][1]; + const k = `${nx},${ny}`; + if (seen.has(k)) continue; + if (!walkable(nx, ny)) continue; + seen.add(k); + if (!inAnswer(nx, ny)) { + return { x: nx + 0.5, y: ny + 0.5 }; + } + q.push([nx, ny]); + } + } + const sp = md.spawn || { x: 1, y: 1 }; + return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; + } + + function quizResolveChoiceFromStanding(ox, oy, correctTrue) { + const tx = Math.floor(ox); + const ty = Math.floor(oy); + const qt = mapData.quizTrueArea; + const qf = mapData.quizFalseArea; + const inT = quizCellOnPlay(qt, tx, ty); + const inF = quizCellOnPlay(qf, tx, ty); + let choice = null; + if (inT && !inF) choice = true; + else if (inF && !inT) choice = false; + else if (inT && inF) choice = true; + const right = choice !== null && choice === correctTrue; + return { choice, right }; + } + + /** เฉลยรอบทดสอบบนเครื่อง — ให้พฤติกรรมใกล้เคียง server (ผิด = ล็อกโซน + ดีดออก) */ + function resolvePreviewQuizRound() { + if (!previewMode || !mapData || !isQuiz() || !previewQuizCurrent || myId == null) return; + const correctTrue = !!previewQuizCurrent.answerTrue; + const results = []; + const mine = quizResolveChoiceFromStanding(me.x, me.y, correctTrue); + let right = mine.right; + let choice = mine.choice; + if (mine.right) { + playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1; + } else { + playQuizPlayerLocal.cannotTrue = true; + playQuizPlayerLocal.cannotFalse = true; + const sp = pickRandomSpawnFromMapPlay(mapData); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + me.x = pos.x; + me.y = pos.y; + socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); + } + results.push({ + id: myId, + right, + choice, + score: playLiveQuizScores[myId] != null ? playLiveQuizScores[myId] : 0, + }); + if (previewFillBots) { + others.forEach((o, id) => { + if (!isPreviewBotId(id)) return; + const br = quizResolveChoiceFromStanding(o.x, o.y, correctTrue); + o.botPath = []; + o.botAnswerWander = false; + if (br.right) { + playLiveQuizScores[id] = (playLiveQuizScores[id] || 0) + 1; + } else { + o.quizCannotTrue = true; + o.quizCannotFalse = true; + const sp = pickRandomSpawnFromMapPlay(mapData); + const pos = findNearestOutsideQuizAnswerZonesPlay(mapData, sp.x + 0.5, sp.y + 0.5); + o.x = pos.x + (Math.random() - 0.5) * 0.35; + o.y = pos.y + (Math.random() - 0.5) * 0.35; + } + results.push({ + id, + right: br.right, + choice: br.choice, + score: playLiveQuizScores[id] != null ? playLiveQuizScores[id] : 0, + }); + }); + } + const r = { results, scores: { ...playLiveQuizScores } }; + renderPlayQuizScoreboard(playLiveQuizScores); + showPlayQuizFeedback(r); + } + + function advancePreviewQuizIfDue() { + if (!previewMode || !isQuiz() || !playQuizPhaseEndsAt || Date.now() < playQuizPhaseEndsAt) return; + const pool = previewQuizPool; + if (!pool || !pool.length) return; + if (previewQuizStep === 'read') { + applyPreviewAnswerPhase(); + return; + } + if (previewQuizStep === 'answer') { + resolvePreviewQuizRound(); + applyPreviewBetweenPhase(); + return; + } + if (previewQuizStep === 'between') { + const q = pickRandomQuizFromPool(pool); + if (q) applyPreviewReadPhase(q, pool.length); + } + } + + async function loadPreviewQuizAndStart() { + resetPreviewBotsQuizState(); + let settings = null; + try { + const r = await fetch(SERVER + '/api/quiz-settings'); + if (r.ok) settings = await r.json(); + } catch (e) { /* use map fallback */ } + let pool = []; + if (settings && Array.isArray(settings.questions) && settings.questions.length) { + pool = settings.questions + .filter((q) => q && String(q.text || '').trim()) + .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })); + } + if (!pool.length && mapData) pool = buildQuizPoolFromMap(mapData); + previewQuizPool = pool; + const dRead = 10000; + const dAns = 5000; + const dBet = 3500; + previewQuizTiming = { + readMs: clampPreviewMs(settings && settings.readMs, dRead, 1000, 300000), + answerMs: clampPreviewMs(settings && settings.answerMs, dAns, 1000, 300000), + betweenMs: clampPreviewMs(settings && settings.betweenMs, dBet, 0, 300000), + }; + previewQuizCurrent = null; + if (!pool.length) { + playQuizPhaseLocal = 'read'; + previewQuizStep = 'read'; + playQuizText = 'ยังไม่มีคำถามในชุด — ตั้งคำถามใน Admin → คำถามเกม หรือในแมป (quizQuestions)'; + playQuizPhaseEndsAt = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] ไม่มีคำถามในระบบ'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = playQuizText; + const leg = document.getElementById('quiz-play-legend'); + if (leg) leg.textContent = 'โซนสีบนแผนที่ยังใช้ดูเลย์เอาต์ได้ตามปกติ'; + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + return; + } + const q = pickRandomQuizFromPool(pool); + if (q) applyPreviewReadPhase(q, pool.length); + initPlayLiveQuizScoresZeros(); + startPlayQuizTimer(); + } + + function startPlayQuizTimer() { + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(() => { + updatePlayQuizTimerDisplay(); + advancePreviewQuizIfDue(); + }, 200); + updatePlayQuizTimerDisplay(); + } + + function teardownPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + const panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + playQuizPhaseLocal = null; + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + playQuizText = ''; + playQuizPhaseEndsAt = 0; + previewQuizPool = []; + previewQuizCurrent = null; + previewQuizStep = 'read'; + playLiveQuizScores = {}; + playPath = []; + hidePlayQuizHud(); + } + + function setupPlayQuizUi() { + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + const leg = document.getElementById('quiz-play-legend'); + if (previewMode) { + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = '[ทดสอบ] กำลังโหลดคำถาม…'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'ดึงชุดคำถามจาก /api/quiz-settings (หรือจากแมป)…'; + if (leg) leg.textContent = ''; + loadPreviewQuizAndStart(); + } else { + playQuizPhaseLocal = null; + playQuizText = 'รอคำถามจากโฮสต์…'; + playQuizPhaseEndsAt = 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + if (phaseEl) phaseEl.textContent = 'แมพตอบคำถาม'; + const qEl = document.getElementById('quiz-game-question'); + if (qEl) qEl.textContent = 'เข้าร่วมห้องแล้ว — เมื่อโฮสต์เริ่มเกม ข้อความและเวลาจะอัปเดตที่นี่ (เล่นจริงแนะนำผ่าน room-lobby)'; + if (leg) leg.textContent = 'โซนสีบนแผนที่คือจุดตอบเหมือนใน Lobby'; + const tEl = document.getElementById('quiz-game-timer'); + if (tEl) tEl.textContent = ''; + } + } + + function isFrogger() { return mapData && mapData.gameType === 'frogger'; } + function isGauntlet() { return mapData && mapData.gameType === 'gauntlet'; } + + function 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, 'gauntletLaneImageUrls') && Array.isArray(payload.gauntletLaneImageUrls)) { + gauntletLaneImageUrls = payload.gauntletLaneImageUrls.filter((x) => typeof x === 'string').slice(0, 24); + gauntletLaneImageUrls.forEach((x) => ensureGauntletAssetImage(x)); + } + if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserTopUrl')) { + gauntletLaserTopUrl = typeof payload.gauntletLaserTopUrl === 'string' ? payload.gauntletLaserTopUrl : ''; + ensureGauntletAssetImage(gauntletLaserTopUrl); + } + if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserBottomUrl')) { + gauntletLaserBottomUrl = typeof payload.gauntletLaserBottomUrl === 'string' ? payload.gauntletLaserBottomUrl : ''; + ensureGauntletAssetImage(gauntletLaserBottomUrl); + } + if (Object.prototype.hasOwnProperty.call(payload, 'gauntletLaserLineUrl')) { + gauntletLaserLineUrl = 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))); + } + } + + /** เลอร์ปตำแหน่งตัวละครต่อเฟรม (ยิ่งสูงยิ่งตามเป้าเร็ว) */ + 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) => (o && o.id != null ? { id: o.id, kind: o.kind, x: o.x, y: o.y } : null)).filter(Boolean); + } + + /** โค้งยกตัว: ขึ้นถึงจุดสูงสุดเร็ว ลงชัน (รู้สึกลงพื้นเร็วกว่าแบบสมมาตร) */ + 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; + out.push({ id: n.id, kind: n.kind, drawX, y: n.y }); + } + 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) => ({ + id: n.id, + kind: n.kind, + x: curMap.has(n.id) ? curMap.get(n.id) : n.x, + y: n.y, + })); + 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) { + 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; + 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) sameCell = true; + if (obs.x === px + 1 && obs.y === py) incomingCol = true; + } + if (obs.kind === 'laser' && typeof obs.x === 'number') { + if (obs.x === px) sameCell = true; + if (obs.x === px + 1) 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) { + 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; + 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) { + if (air) advanceX = true; + else hitBack = true; + } + if (ob.kind === 'laser' && typeof ob.x === 'number' && ob.x === px) { + if (air) advanceX = true; + else hitBack = true; + } + } + if (advanceX) { + px = Math.min(w - 2, px + 1); + o.gauntletJumpTicks = 0; + o.gauntletScore = (o.gauntletScore || 0) + 1; + } else if (hitBack) { + px = Math.max(0, px - 1); + } + if ((o.gauntletJumpTicks || 0) > 0) o.gauntletJumpTicks--; + o.tx = px; + o.ty = py; + } + + function applyGauntletPreviewBotsAfterSync() { + if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return; + const w = mapData.width || 20; + const h = mapData.height || 15; + others.forEach((o, id) => { + if (!isPreviewBotId(id) || !o) return; + maybePreviewBotGauntletJump(o, gauntletObstacles, w, h); + applyGauntletPhysicsToPreviewBot(o, gauntletObstacles, w, h); + }); + } + + /** แจ้งเซิร์ฟเวอร์แถว y ที่มีคุณ+บอททดสอบ — lane spawn ใช้เฉพาะแถวที่มี peer; บอทไม่ใช่ peer */ + function emitGauntletPreviewRowsToServer() { + if (!previewFillBots || !mapData || mapData.gameType !== 'gauntlet') return; + 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] }); + } + + 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 }; + } + + function applyMapAndStart(gameMapData, res) { + mapData = gameMapData; + 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 = []; + } + tileSize = mapData.tileSize || 32; + mapBackgroundImg = null; + if (mapData.backgroundImage) { + mapBackgroundImg = new Image(); + mapBackgroundImg.src = mapData.backgroundImage; + } + var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' + : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' + : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : ''))); + 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; + 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; + } + gauntletObstacles = []; + gauntletObsRenderPrev = []; + gauntletObsRenderNext = []; + gauntletObsBlendT0 = 0; + me.playTint = pickRandomPlayTint(); + others.clear(); + plist.forEach(p => { + if (p.id !== myId) { + const pos = peerXYFromJoin(p, mapData.spawn); + others.set(p.id, { + x: pos.x, y: pos.y, tx: pos.x, ty: pos.y, direction: p.direction, nickname: p.nickname, characterId: p.characterId, + playTint: playTintFromPeerId(p.id), + gauntletJumpTicks: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + }); + } + }); + 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(SERVER + '/api/game-timing') + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + } else { + me.tx = null; + me.ty = null; + gauntletEndsAtMs = null; + } + rebalancePreviewBots(); + playPath = []; + me.isWalking = false; + if (isQuiz()) setupPlayQuizUi(); + else teardownPlayQuizUi(); + resizeCanvas(); draw(); tick(); + } + + socket.on('connect', () => { + socket.emit('join-space', { spaceId, nickname: nick, characterId: getPlayCharacterId() }, (res) => { + if (!res || !res.ok) { + const errMsg = (res && res.error) || 'เข้าร่วมไม่ได้'; + alert(errMsg); + const caseLocked = /เริ่มคดี|ไม่รับผู้เล่น/.test(errMsg); + if (caseLocked) { + window.location.replace('room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick)); + } else { + window.location.replace(BASE + '/lobby.html'); + } + return; + } + myId = socket.id; + const playMapId = params.get('map'); + if (playMapId) { + fetch(SERVER + '/api/maps/' + encodeURIComponent(playMapId)) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data) applyMapAndStart(data, res); + else applyMapAndStart(res.mapData, res); + }) + .catch(() => applyMapAndStart(res.mapData, res)); + } else { + applyMapAndStart(res.mapData, res); + } + }); + }); + + const LERP = 0.2; + socket.on('user-joined', (data) => { + if (!data || isPreviewBotId(data.id)) return; + const x = Number(data.x); + const y = Number(data.y); + const px = Number.isFinite(x) ? x : 1; + const py = Number.isFinite(y) ? y : 1; + others.set(data.id, { + x: px, y: py, tx: px, ty: py, + direction: data.direction || 'down', nickname: data.nickname, + characterId: data.characterId ?? null, + playTint: playTintFromPeerId(data.id), + gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, + gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + }); + if (previewFillBots) rebalancePreviewBots(); + }); + 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 (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) { + o.tx = data.x; + o.ty = data.y; + o.direction = data.direction || o.direction; + if (data.characterId != null) o.characterId = data.characterId; + } + }); + socket.on('user-left', (data) => { + if (!data || isPreviewBotId(data.id)) return; + others.delete(data.id); + if (previewFillBots) rebalancePreviewBots(); + }); + socket.on('chat', (data) => { + const box = document.getElementById('chat-messages'); + if (!box) return; + const div = document.createElement('div'); + div.className = 'chat-msg'; + div.textContent = (data.nickname || '') + ': ' + (data.text || ''); + box.appendChild(div); + box.scrollTop = 1e9; + }); + + socket.on('quiz-phase', (p) => { + if (previewMode) return; + if (!p || !mapData || !isQuiz()) return; + if (p.text) playQuizText = p.text; + playQuizPhaseLocal = p.phase; + playQuizPhaseEndsAt = p.endsAt || 0; + const phaseEl = document.getElementById('quiz-game-phase-label'); + const qEl = document.getElementById('quiz-game-question'); + if (phaseEl) { + phaseEl.textContent = p.phase === 'read' + ? ('อ่านคำถาม ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')) + : ('เดินไปโซน ถูก / ผิด — ข้อ ' + (p.questionIndex || '') + '/' + (p.questionTotal || '')); + } + if (qEl) qEl.textContent = playQuizText || ''; + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.remove('is-hidden'); + if (playQuizTimerInterval) clearInterval(playQuizTimerInterval); + playQuizTimerInterval = setInterval(updatePlayQuizTimerDisplay, 200); + updatePlayQuizTimerDisplay(); + if (!previewMode && isQuiz() && !Object.keys(playLiveQuizScores).length) initPlayLiveQuizScoresZeros(); + }); + + socket.on('quiz-result', (r) => { + if (previewMode || !r) return; + if (r.scores) { + playLiveQuizScores = { ...r.scores }; + renderPlayQuizScoreboard(playLiveQuizScores); + } + showPlayQuizFeedback(r); + }); + + socket.on('quiz-player-state', (st) => { + if (previewMode) return; + if (!st) return; + playQuizPlayerLocal = { + cannotTrue: !!st.cannotTrue, + cannotFalse: !!st.cannotFalse, + eliminated: !!st.eliminated, + score: typeof st.score === 'number' ? st.score : (playQuizPlayerLocal.score || 0), + }; + }); + + socket.on('quiz-ended', () => { + if (previewMode && isQuiz()) return; + playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; + playQuizPhaseLocal = null; + if (playQuizTimerInterval) { + clearInterval(playQuizTimerInterval); + playQuizTimerInterval = null; + } + const ov = document.getElementById('quiz-game-overlay'); + if (ov) ov.classList.add('is-hidden'); + const panel = document.getElementById('quiz-map-question-panel'); + if (panel) { + panel.classList.add('is-hidden'); + panel.setAttribute('aria-hidden', 'true'); + } + playQuizText = ''; + playQuizPhaseEndsAt = 0; + playLiveQuizScores = {}; + hidePlayQuizHud(); + }); + + socket.on('gauntlet-sync', (data) => { + if (!mapData || mapData.gameType !== 'gauntlet' || !data) return; + applyGauntletTimingFromServer(data); + if (Array.isArray(data.obstacles)) { + gauntletObstacles = data.obstacles; + pushGauntletObsRenderFrame(data.obstacles); + } + if (!Array.isArray(data.players)) return; + data.players.forEach((p) => { + if (!p || p.id == null || myId == null) return; + const pid = String(p.id); + const mid = String(myId); + if (pid === mid) { + const px = Number(p.x); + const py = Number(p.y); + if (Number.isFinite(px)) me.tx = px; + if (Number.isFinite(py)) me.ty = py; + if (p.direction) me.direction = p.direction; + const jt = Number(p.gauntletJumpTicks); + meGauntletJumpTicks = Number.isFinite(jt) ? jt : 0; + const sc = Number(p.gauntletScore); + me.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; + } else { + const o = others.get(p.id) ?? others.get(pid); + if (o) { + const px = Number(p.x); + const py = Number(p.y); + if (Number.isFinite(px)) o.tx = px; + if (Number.isFinite(py)) o.ty = py; + if (p.direction) o.direction = p.direction; + const jt = Number(p.gauntletJumpTicks); + o.gauntletJumpTicks = Number.isFinite(jt) ? jt : 0; + if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks; + const sc = Number(p.gauntletScore); + o.gauntletScore = Number.isFinite(sc) ? Math.max(0, sc) : 0; + } + } + }); + if (previewFillBots) { + applyGauntletPreviewBotsAfterSync(); + emitGauntletPreviewRowsToServer(); + } + }); + + socket.on('gauntlet-ended', (data) => { + 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; + gauntletEndsAtMs = null; + 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 applySnap = (md) => { + mapData = md; + 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; + } + 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; + } 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; + } + } + }); + } else { + meGauntletJumpTicks = 0; + meGauntletJumpVis = 0; + me.gauntletScore = 0; + others.forEach((o) => { + o.gauntletJumpTicks = 0; + o.gauntletJumpVis = 0; + o.gauntletScore = 0; + }); + } + if (mapData.gameType === 'gauntlet') { + if (ev.gauntletEndsAt != null) { + const ge = Number(ev.gauntletEndsAt); + gauntletEndsAtMs = Number.isFinite(ge) ? ge : null; + } else { + gauntletEndsAtMs = null; + } + fetch(SERVER + '/api/game-timing') + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + } else { + gauntletEndsAtMs = null; + } + if (mapData.gameType !== 'gauntlet') { + me.tx = null; + me.ty = null; + } + const gauntletOv = document.getElementById('gauntlet-ended-overlay'); + if (gauntletOv) gauntletOv.classList.add('is-hidden'); + var modeLabel = isFrogger() ? ' | โหมดกบข้ามถนน' + : (isGauntlet() ? ' | พรมแดงสุดท้าย (Last Light)' + : (isLobby() ? ' | โถงรอ' : (isQuiz() ? ' | ตอบคำถาม' : ''))); + var prevTag = previewMode ? '[ทดสอบ] ' : ''; + document.getElementById('room-id').textContent = prevTag + spaceId + modeLabel; + if (isQuiz()) setupPlayQuizUi(); + else teardownPlayQuizUi(); + resizeCanvas(); + if (previewFillBots && mapData.gameType === 'gauntlet') { + applyGauntletPreviewSpawnLayout(true); + emitGauntletPreviewRowsToServer(); + } + }; + fetch(SERVER + '/api/maps/' + encodeURIComponent(ev.mapId)) + .then((r) => (r.ok ? r.json() : null)) + .then((md) => { if (md) applySnap(md); }); + }); + + function resizeCanvas() { + // ใช้ขนาดจริงของ element (viewport) เพื่อให้กล้องเลื่อนตามตัวละคร + const vw = window.innerWidth || document.documentElement.clientWidth || 800; + const vh = window.innerHeight || document.documentElement.clientHeight || 600; + const cw = canvas.clientWidth || 0; + const ch = canvas.clientHeight || 0; + canvas.width = Math.max(320, cw || vw); + canvas.height = Math.max(240, ch || vh); + } + + // ให้ scroll wheel ใช้ซูมเข้า/ออกแบบง่าย ๆ + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.1 : 0.9; + zoom = Math.max(0.7, Math.min(2.5, zoom * factor)); + }, { passive: false }); + canvas.addEventListener('dblclick', (e) => { + if (!mapData || isFrogger() || isGauntlet() || isChatFocused()) return; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + const camX = me.x * tileSize; + const camY = me.y * tileSize; + const gx = (sx - canvas.width / 2) / zoom + camX; + const gy = (sy - canvas.height / 2) / zoom + camY; + const tx = Math.floor(gx / tileSize); + const ty = Math.floor(gy / tileSize); + const mw = mapData.width || 20, mh = mapData.height || 15; + if (tx < 0 || tx >= mw || ty < 0 || ty >= mh) return; + if (!canWalkLikeLobby(tx + 0.5, ty + 0.5)) return; + const path = pathfindPlay(me.x, me.y, tx + 0.5, ty + 0.5); + if (path.length <= 1) return; + playPath = path.slice(1); + }); + + function quizTilesFootprintPlay(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 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; + 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 row = mapData.objects[ty]; + if (!row || row[tx] === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, o] of others) { + if (Math.floor(o.x) === tx && Math.floor(o.y) === 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; + } + } + return true; + } + + function canWalkLikeLobbyForBot(x, y, fromX, fromY, o) { + if (!mapData || !mapData.objects) 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 row = mapData.objects[ty]; + if (!row || row[tx] === 1) return false; + const bp = mapData.blockPlayer; + if (bp && bp[ty] && bp[ty][tx] === 1) { + for (const [, peer] of others) { + if (o && peer === o) continue; + if (Math.floor(peer.x) === tx && Math.floor(peer.y) === 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; + } + } + } + return true; + } + + 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(); + } + o.x = Math.max(0, Math.min(w - 0.01, o.x)); + o.y = Math.max(0, Math.min(h - 0.01, o.y)); + return; + } + const len = dist || 1; + const step = Math.min(MOVE_SPEED * 1.28, 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; + 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; + } + o.x = Math.max(0, Math.min(w - 0.01, o.x)); + o.y = Math.max(0, Math.min(h - 0.01, o.y)); + if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; + } + + function pickRandomPreviewBotWanderDir() { + const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]]; + return dirs[Math.floor(Math.random() * dirs.length)]; + } + + function stepPreviewBots() { + if (!previewFillBots || !mapData || isFrogger() || isGauntlet()) 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; + 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); + } + o.x = Math.max(0, Math.min(w - 0.01, o.x)); + o.y = Math.max(0, Math.min(h - 0.01, o.y)); + if (Math.abs(o.x - ox) > 1e-5 || Math.abs(o.y - oy) > 1e-5) o.botIsWalking = true; + }); + } + + /** A* เหมือน room-lobby — double-click ไปจุดบนแผนที่ */ + function pathfindPlay(fromX, fromY, toX, toY) { + if (!mapData) return []; + const w = mapData.width || 20, h = mapData.height || 15; + const fx = Math.floor(fromX), fy = Math.floor(fromY); + const tx = Math.floor(toX), ty = Math.floor(toY); + if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobby(tx + 0.5, ty + 0.5)) return []; + if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }]; + const key = (gx, gy) => gx + ',' + gy; + const open = [{ gx: fx, gy: fy, f: 0, g: 0 }]; + const closed = new Set(); + const cameFrom = {}; + const gScore = { [key(fx, fy)]: 0 }; + const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty); + const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; + while (open.length) { + open.sort((a, b) => a.f - b.f); + const cur = open.shift(); + const ck = key(cur.gx, cur.gy); + if (closed.has(ck)) continue; + closed.add(ck); + if (cur.gx === tx && cur.gy === ty) { + const path = []; + let u = cur; + while (u) { + path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 }); + u = cameFrom[key(u.gx, u.gy)]; + } + return path; + } + for (const d of dirs) { + const nx = cur.gx + d.dx, ny = cur.gy + d.dy; + if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; + if (!canWalkLikeLobby(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5)) continue; + const nk = key(nx, ny); + if (closed.has(nk)) continue; + const g = (gScore[ck] ?? Infinity) + 1; + if (g >= (gScore[nk] ?? Infinity)) continue; + gScore[nk] = g; + cameFrom[nk] = cur; + open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g }); + } + } + return []; + } + + function pathfindPlayForBot(fromX, fromY, toX, toY, o) { + if (!mapData) return []; + const w = mapData.width || 20, h = mapData.height || 15; + const fx = Math.floor(fromX), fy = Math.floor(fromY); + const tx = Math.floor(toX), ty = Math.floor(toY); + if (tx < 0 || tx >= w || ty < 0 || ty >= h || !canWalkLikeLobbyForBot(tx + 0.5, ty + 0.5, NaN, NaN, o)) return []; + if (fx === tx && fy === ty) return [{ x: tx + 0.5, y: ty + 0.5 }]; + const key = (gx, gy) => gx + ',' + gy; + const open = [{ gx: fx, gy: fy, f: 0, g: 0 }]; + const closed = new Set(); + const cameFrom = {}; + const gScore = { [key(fx, fy)]: 0 }; + const heuristic = (ax, ay) => Math.abs(ax - tx) + Math.abs(ay - ty); + const dirs = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; + while (open.length) { + open.sort((a, b) => a.f - b.f); + const cur = open.shift(); + const ck = key(cur.gx, cur.gy); + if (closed.has(ck)) continue; + closed.add(ck); + if (cur.gx === tx && cur.gy === ty) { + const path = []; + let u = cur; + while (u) { + path.unshift({ x: u.gx + 0.5, y: u.gy + 0.5 }); + u = cameFrom[key(u.gx, u.gy)]; + } + return path; + } + for (const d of dirs) { + const nx = cur.gx + d.dx, ny = cur.gy + d.dy; + if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; + if (!canWalkLikeLobbyForBot(nx + 0.5, ny + 0.5, cur.gx + 0.5, cur.gy + 0.5, o)) continue; + const nk = key(nx, ny); + if (closed.has(nk)) continue; + const g = (gScore[ck] ?? Infinity) + 1; + if (g >= (gScore[nk] ?? Infinity)) continue; + gScore[nk] = g; + cameFrom[nk] = cur; + open.push({ gx: nx, gy: ny, f: g + heuristic(nx, ny), g }); + } + } + return []; + } + + let playPath = []; + + function drawGauntletLaserColumnScreen(rx, ry, rw, rh) { + ctx.save(); + ctx.fillStyle = gauntletLaserFillColor; + ctx.fillRect(rx, ry, rw, rh); + const topRec = gauntletLaserTopUrl ? ensureGauntletAssetImage(gauntletLaserTopUrl) : null; + const botRec = gauntletLaserBottomUrl ? ensureGauntletAssetImage(gauntletLaserBottomUrl) : null; + const lineRec = gauntletLaserLineUrl ? ensureGauntletAssetImage(gauntletLaserLineUrl) : null; + let topH = 0; + let botH = 0; + if (topRec && topRec.img.complete && topRec.img.naturalWidth > 0) { + topH = Math.min(rh * 0.4, rw * topRec.img.naturalHeight / topRec.img.naturalWidth); + try { ctx.drawImage(topRec.img, rx, ry, rw, topH); } catch (e) { /* ignore */ } + } + if (botRec && botRec.img.complete && botRec.img.naturalWidth > 0) { + botH = Math.min(rh * 0.4, rw * botRec.img.naturalHeight / botRec.img.naturalWidth); + try { ctx.drawImage(botRec.img, rx, ry + rh - botH, rw, botH); } catch (e) { /* ignore */ } + } + const midTop = ry + topH; + const midH = Math.max(0, rh - topH - botH); + if (lineRec && lineRec.img.complete && lineRec.img.naturalWidth > 0 && midH > 1) { + const iw = lineRec.img.naturalWidth; + const ih = lineRec.img.naturalHeight; + const scale = rw / iw; + const step = Math.max(1, ih * scale); + let y = midTop; + while (y < midTop + midH) { + const piece = Math.min(step, midTop + midH - y); + const srcH = piece / scale; + try { + ctx.drawImage(lineRec.img, 0, 0, iw, srcH, rx, y, rw, piece); + } catch (e) { /* ignore */ } + y += piece; + } + } + 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(); + } + + function draw() { + if (!mapData) return; + const w = mapData.width, h = mapData.height; + + // ศูนย์กลางกล้องคือผู้เล่น + const camX = me.x * tileSize; + const camY = me.y * tileSize; + const halfW = canvas.width / (2 * zoom); + const halfH = canvas.height / (2 * zoom); + + // world bounds ที่กล้องมองเห็น (เป็นพิกัดพิกเซลของ map) + const worldMinX = camX - halfW; + const worldMaxX = camX + halfW; + const worldMinY = camY - halfH; + const worldMaxY = camY + halfH; + + const mapWpx = w * tileSize, mapHpx = h * tileSize; + const visibleW = worldMaxX - worldMinX, visibleH = worldMaxY - worldMinY; + const showGrid = mapData.showMapInGame !== false && mapData.showMapInGame !== 'false'; + const timeMs = Date.now(); + + if (mapBackgroundImg && mapBackgroundImg.complete && mapBackgroundImg.naturalWidth) { + ctx.drawImage(mapBackgroundImg, + (worldMinX / mapWpx) * mapBackgroundImg.naturalWidth, (worldMinY / mapHpx) * mapBackgroundImg.naturalHeight, + (visibleW / mapWpx) * mapBackgroundImg.naturalWidth, (visibleH / mapHpx) * mapBackgroundImg.naturalHeight, + 0, 0, canvas.width, canvas.height); + } else { + ctx.fillStyle = '#1a1b26'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + function worldToScreen(wx, wy) { + const sx = (wx - camX) * zoom + canvas.width / 2; + const sy = (wy - camY) * zoom + canvas.height / 2; + return [sx, sy]; + } + + if (showGrid) { + 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)); + 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)'; + } + for (let x = startTileX; x <= endTileX; x++) { + const wx = x * tileSize, wy = y * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const size = tileSize * zoom; + 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); + } + } + 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 (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 * zoom * (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); + } + } + } + } + + const gauntletObsDraw = isGauntlet() ? getGauntletObsDrawPositionsAt(performance.now()) : []; + if (isGauntlet() && gauntletObsDraw.length) { + const stx = Math.max(0, Math.floor(worldMinX / tileSize)); + const enx = Math.min(w - 1, Math.ceil(worldMaxX / tileSize)); + const sty = Math.max(0, Math.floor(worldMinY / tileSize)); + const eny = Math.min(h - 1, Math.ceil(worldMaxY / tileSize)); + for (let i = 0; i < gauntletObsDraw.length; i++) { + const o = gauntletObsDraw[i]; + if (!o) continue; + if (o.kind === 'lane' && typeof o.y === 'number') { + if (o.drawX < stx - 2 || o.drawX > enx + 2 || o.y < sty || o.y > eny) continue; + const wx = o.drawX * tileSize, wy = o.y * tileSize; + const [sx, sy] = worldToScreen(wx, wy); + const size = tileSize * zoom; + const laneRec = pickGauntletLaneImageRec(o.id); + if (laneRec && laneRec.img.complete && laneRec.img.naturalWidth > 0) { + try { + ctx.drawImage(laneRec.img, sx + 2, sy + 2, size - 4, size - 4); + } catch (e) { + ctx.fillStyle = '#f7768e'; + ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); + } + ctx.strokeStyle = '#ff9ebc'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.lineWidth = 1; + } else { + ctx.fillStyle = '#f7768e'; + ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.strokeStyle = '#ff9ebc'; + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4); + ctx.lineWidth = 1; + } + } else if (o.kind === 'laser' && typeof o.drawX === 'number') { + if (o.drawX < stx - 2 || o.drawX > enx + 2) continue; + const wx0 = o.drawX * tileSize; + const wx1 = (o.drawX + 1) * tileSize; + const wy0 = 0; + const wy1 = h * 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 drawAvatar(ax, ay, isMe, name, characterId, direction, isWalking, playTint, gauntletAirTicks, gauntletScoreLabel) { + const cells = Math.max(1, Math.min(4, mapData.characterCells || 1)); + const air = Number(gauntletAirTicks) || 0; + let liftWorldY = 0; + if (isGauntlet() && air > 0) { + liftWorldY = gauntletLiftHeightNorm(air, gauntletRuntimeJumpTicks) * tileSize * 0.52; + } + const cxWorld = (ax + cells * 0.5) * tileSize; + const cyBottomWorld = (ay + 1) * tileSize - liftWorldY; + const [sx, sy] = worldToScreen(cxWorld, cyBottomWorld); + const r = Math.max(14, (tileSize * zoom * cells) / 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; + } + if (iw > 0 && ih > 0) { + const scale = Math.min(size / iw, size / ih, 1); + const drawW = iw * scale; + const drawH = ih * scale; + 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(); + } + 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); + } + const safeX = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); + const safeY = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); + function peerVisualOffset(id) { + let h = 0; + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + (id || '').charCodeAt(i)) >>> 0; + return { ax: ((h % 5) - 2) * 0.1, ay: ((Math.floor(h / 5) % 5) - 2) * 0.1 }; + } + const othersSorted = [...others.entries()].sort((a, b) => { + const oa = a[1], ob = b[1]; + const ya = safeY(oa.y), yb = safeY(ob.y); + if (Math.abs(ya - yb) > 0.01) return ya - yb; + return safeX(oa.x) - safeX(ob.x); + }); + othersSorted.forEach(([id, o]) => { + const off = isGauntlet() ? { ax: 0, ay: 0 } : peerVisualOffset(id); + const otherWalk = isGauntlet() + ? ((o.gauntletJumpTicks || 0) > 0 || (o.gauntletJumpVis || 0) > 0.08) + : (isPreviewBotId(id) + ? !!o.botIsWalking + : !!((o.tx != null && Math.abs((o.tx || o.x) - o.x) > 0.02) || (o.ty != null && Math.abs((o.ty || o.y) - o.y) > 0.02))); + const ot = o.playTint || playTintFromPeerId(id); + drawAvatar(safeX(o.x) + off.ax, safeY(o.y) + off.ay, false, o.nickname, o.characterId, o.direction, otherWalk, ot, (o.gauntletJumpVis != null ? o.gauntletJumpVis : o.gauntletJumpTicks) || 0, o.gauntletScore || 0); + }); + const mt = me.playTint || pickRandomPlayTint(); + if (!me.playTint) me.playTint = mt; + const meTag = isFrogger() ? (' (กบ) ' + froggerScore) : (isGauntlet() && meGauntletJumpTicks > 0 ? ' (กระโดด)' : ' (คุณ)'); + const meWalking = isFrogger() ? false : (isGauntlet() ? (meGauntletJumpTicks > 0 || meGauntletJumpVis > 0.08) : !!me.isWalking); + drawAvatar(safeX(me.x), safeY(me.y), true, me.nickname + meTag, me.characterId, me.direction, meWalking, mt, meGauntletJumpVis, me.gauntletScore || 0); + if (isFrogger()) { + ctx.fillStyle = '#7aa2f7'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('โหมดกบ | คะแนน: ' + froggerScore, 10, 24); + } + if (isGauntlet()) { + ctx.fillStyle = '#f7768e'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('พรมแดงสุดท้าย | คะแนน: ' + (me.gauntletScore || 0) + ' (ข้าม 1 ชิ้น +1) · สูงสุด 6 คน', 10, 24); + ctx.font = '12px sans-serif'; + ctx.fillStyle = '#c0caf5'; + ctx.fillText('Space / W / ↑ = กระโดด · เลน + เลเซอร์ · ข้ามสำเร็จ → ขวา 1 + คะแนน · ชน → ซ้าย 1', 10, 42); + ctx.fillStyle = '#e0af68'; + if (gauntletEndsAtMs != null && Number.isFinite(gauntletEndsAtMs)) { + const rem = Math.max(0, Math.ceil((gauntletEndsAtMs - Date.now()) / 1000)); + const mm = Math.floor(rem / 60); + const ss = rem % 60; + const clock = `${mm}:${String(ss).padStart(2, '0')}`; + ctx.fillText(`เหลือเวลา ${clock} · Time left ${clock}`, 10, 60); + } else if (gauntletRuntimeTimeLimitSec > 0) { + ctx.fillText('เวลา: กำลังซิงก์จากเซิร์ฟเวอร์... · Syncing timer...', 10, 60); + } else { + ctx.fillText('เวลา: ไม่จำกัด · No time limit (ตั้งได้ที่ Admin → เวลาเกม)', 10, 60); + } + } + if (isQuiz()) syncPlayQuizMapPanel(); + if (previewFillBots && mapData) { + 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)'; + ctx.fillRect(6, canvas.height - 38, Math.min(canvas.width - 12, 320), 30); + ctx.fillStyle = '#a9b1d6'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('ทดสอบ: คนจริง ' + human + ' + บอท ' + bots + ' (เป้า ' + previewTargetHeadcount + ' คน)', 12, canvas.height - 18); + ctx.restore(); + } + } + + document.addEventListener('keydown', (e) => { + if (isMovementKey(e.code) && isChatFocused()) return; + if (isGauntlet() && mapData && !isChatFocused()) { + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { + e.preventDefault(); + const now = Date.now(); + if (now - lastGauntletJumpKey < 200) return; + lastGauntletJumpKey = now; + socket.emit('gauntlet-jump'); + return; + } + if (['ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { + e.preventDefault(); + return; + } + } + 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 (isFrogger()) { + if (checkFroggerCollision()) respawnFrogger(); + me.isWalking = false; + draw(); + requestAnimationFrame(tick); + return; + } + if (isGauntlet()) { + me.isWalking = meGauntletJumpTicks > 0; + const stepGauntletJumpVis = (vis, tgt) => { + const t = Number(tgt) || 0; + let v = Number(vis) || 0; + const d = t - v; + const k = d < 0 ? 0.68 : 0.4; + return v + d * k; + }; + meGauntletJumpVis = stepGauntletJumpVis(meGauntletJumpVis, meGauntletJumpTicks); + const mp = lerpGauntletEntityPos(me.x, me.y, me.tx, me.ty); + me.x = mp.nx; + me.y = mp.ny; + others.forEach((o) => { + const op = lerpGauntletEntityPos(o.x, o.y, o.tx, o.ty); + o.x = op.nx; + o.y = op.ny; + if (o.gauntletJumpVis == null) o.gauntletJumpVis = o.gauntletJumpTicks || 0; + o.gauntletJumpVis = stepGauntletJumpVis(o.gauntletJumpVis, o.gauntletJumpTicks || 0); + }); + draw(); + requestAnimationFrame(tick); + return; + } + /* บอท preview ขยับที่ o.x/o.y โดยตรง — ห้าม LERP กับ tx/ty เพราะ stepPreviewBots เคยตั้ง tx=ก่อนเดิน ทำให้โดนดึงถอย ~20%/เฟรม กระตุกและแทบไม่ไป */ + others.forEach((o, id) => { + if (previewFillBots && isPreviewBotId(id)) return; + if (o.tx != null) o.x += (o.tx - o.x) * LERP; + if (o.ty != null) o.y += (o.ty - o.y) * LERP; + }); + stepPreviewBots(); + if (isChatFocused()) { + me.isWalking = false; + 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)); + const t = Date.now(); + if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } + draw(); + requestAnimationFrame(tick); + return; + } + usePath = true; + const next = playPath[0]; + accX = next.x - me.x; + accY = next.y - me.y; + } else { + usePath = true; + accX = dx; + accY = dy; + } + if (usePath && (accX !== 0 || accY !== 0)) { + if (Math.abs(accY) > Math.abs(accX)) me.direction = accY > 0 ? 'down' : 'up'; + else me.direction = accX > 0 ? 'right' : 'left'; + } + } + if (!usePath) { + if (keys['ArrowUp'] || keys['KeyW']) { accY = -1; me.direction = 'up'; } + if (keys['ArrowDown'] || keys['KeyS']) { accY = 1; me.direction = 'down'; } + if (keys['ArrowLeft'] || keys['KeyA']) { accX = -1; me.direction = 'left'; } + if (keys['ArrowRight'] || keys['KeyD']) { accX = 1; me.direction = 'right'; } + } + const preWalkX = me.x, preWalkY = me.y; + if (accX !== 0 || accY !== 0) { + const len = Math.sqrt(accX * accX + accY * accY) || 1; + const step = Math.min(MOVE_SPEED, len); + const nx = me.x + (accX / len) * step; + const ny = me.y + (accY / len) * step; + if (canWalkLikeLobby(nx, ny, me.x, me.y)) { + me.x = nx; + me.y = ny; + } else 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)); + 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'); + } + }); + window.addEventListener('beforeunload', () => socket.emit('leave-space')); + window.addEventListener('resize', () => { + if (!mapData || !canvas) return; + resizeCanvas(); + draw(); + }); +})(); diff --git a/www/html/Game/public/TryChangeCSS/js/room-lobby.js b/www/html/Game/public/TryChangeCSS/js/room-lobby.js new file mode 100644 index 0000000..75e495d --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/room-lobby.js @@ -0,0 +1,3451 @@ +(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'); + 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'; + + 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 suspectPickOverlayOpen = false; + let suspectSelectedIndex = 0; + /** ตรงกับเซิร์ฟ — เฟสเลือกผู้ต้องสงสัยยังไม่จบ (ปิด overlay แล้วยังเปิดได้จากปุ่มโถง) */ + let serverSuspectPhaseActive = false; + 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 = {}; + 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 key = id + '_' + (direction || 'down'); + if (characterImages[key]) return characterImages[key]; + const img = new Image(); + img.src = SERVER + '/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 key = id + '_' + dir; + let anim = characterAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterAnimations[key] = anim; + anim.fallback = getCharacterImg(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'; + anim.frames.push(img); + } + if (mapData && canvas) drawLobbyMap(); + }; + frame0.onerror = function () { if (mapData && canvas) drawLobbyMap(); }; + frame0.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_' + dir + '_0.png'; + anim.frames.push(frame0); + } + var phase = walkAnimPhaseIndex(now, isWalking); + 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; + } + 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) { + 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 }; + } + const peerList = [...peers.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 && Math.abs((p.tx || p.x) - p.x) > 0.02) || (p.ty != null && Math.abs((p.ty || p.y) - p.y) > 0.02)); + 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 charImg = getAvatarImg(p.characterId, 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 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; + } + + function hostStandingInStartGameArea() { + var me = peers.get(socket.id); + if (!me || !mapData) return false; + var tx = Math.floor(me.x), ty = Math.floor(me.y); + var row = mapData.startGameArea && mapData.startGameArea[ty]; + return !!(row && row[tx] === 1); + } + + 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 */ + const LOBBY_A_MAP_NAME = 'LobbyA'; + + function isCurrentRoomLobbyA() { + if (!mapData) return false; + return String(mapData.name || '').trim().toLowerCase() === LOBBY_A_MAP_NAME.toLowerCase(); + } + + 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; + 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; + } + }); + 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 updatePlayersHud() { + if (roomPlayersHud) roomPlayersHud.textContent = 'PLAYERS : ' + peers.size + '/' + maxPlayers; + const sph = document.getElementById('suspect-players-hud'); + if (sph) sph.textContent = 'PLAYERS : ' + peers.size + '/' + maxPlayers; + } + + 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); + } + + const DEFAULT_CHARACTER_ID = 'Chatest'; + function getStoredCharacterId() { + try { return localStorage.getItem('gameCharacterId') || DEFAULT_CHARACTER_ID; } catch (e) { return DEFAULT_CHARACTER_ID; } + } + + function updateLobbyProfileAvatar() { + const img = document.getElementById('room-lobby-profile-avatar'); + if (!img) return; + const id = getStoredCharacterId(); + img.src = SERVER + '/img/characters/' + encodeURIComponent(id) + '_down.png?ch=' + encodeURIComponent(id); + img.alt = (profileDisplayName || nick || 'ผู้เล่น') + ' — ตัวละคร'; + } + + 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; + 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; + updatePlayersHud(); + + 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(); + + serverSuspectPhaseActive = !!res.suspectPhaseActive; + if (res.suspectPhaseActive) { + openSuspectOverlay(res.suspectPickIndex != null ? res.suspectPickIndex : 0); + } else { + updateSuspectFloatingOpenBtn(); + } + + 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 = ''; + av.src = SERVER + '/img/characters/' + encodeURIComponent(row.characterId) + '_down.png'; + } + 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 : 'จบเกมตอบคำถาม')); + redrawLobbyMap(); + }); + + 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 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); + }); + } + + 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'); + 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; + 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); + appendLobbySystemChat('— เริ่มสืบสวน · ผู้ต้องสงสัยหมายเลข ' + n); + }); + + 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) socket.emit('suspect-pick-select', { index: idx }); + }); + + document.getElementById('suspect-btn-start')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + socket.emit('suspect-pick-start', {}, (res) => { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : 'เริ่มสืบสวนไม่สำเร็จ'; + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-pick-start', res); } catch (e2) { /* ignore */ } + }); + }); + + document.getElementById('suspect-btn-accuse')?.addEventListener('click', () => { + if (!suspectPickOverlayOpen || hostId !== socket.id) return; + const pickNumber = Number.isFinite(suspectSelectedIndex) ? (suspectSelectedIndex + 1) : 1; + appendLobbySystemChat('— Host ชี้ตัวคนร้ายหมายเลข ' + pickNumber); + socket.emit('suspect-pick-start', {}, (res) => { + if (res && res.ok) return; + const err = (res && res.error) ? String(res.error) : 'ชี้ตัวคนร้ายไม่สำเร็จ'; + appendLobbySystemChat('— ' + err); + try { console.warn('suspect-btn-accuse', res); } catch (e2) { /* ignore */ } + }); + }); + + 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 = []; + (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(); + appendLobbySystemChat('— ย้ายไป LobbyB แล้ว · ห้องนี้ไม่รับผู้เล่นใหม่'); + tryTroublesomeLobbyGate(); + updateSuspectFloatingOpenBtn(); + syncLobbyBUiChrome(); + 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) => { + serverSuspectPhaseActive = false; + closeTroublesomeOverlay(); + closeSuspectOverlay(); + closeLobbyPreplayWizard(); + 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 = []; + 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; + } + 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); + 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; + if (hostCanOpenLobbyAPreplayWithF()) { + e.preventDefault(); + openLobbyPreplayWizard(); + return; + } + const target = getLobbyInteractTarget(me); + if (!target) { + e.preventDefault(); + return; + } + /* ทุกแบบแผนที่ในโถง (รวม quiz) — ต้องพร้อมก่อน F โต้ตอบช่องเขียว */ + if (!me.ready) { + e.preventDefault(); + appendLobbySystemChat('— กดพร้อมก่อน แล้วค่อยกด 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(); + + window.addEventListener('pageshow', function () { + syncLobbyAvatarFromStorage(); + }); + 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/TryChangeCSS/js/version.js b/www/html/Game/public/TryChangeCSS/js/version.js new file mode 100644 index 0000000..a1eaa33 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/js/version.js @@ -0,0 +1,7 @@ +// ทุกครั้งที่มีการเพิ่มหรือเปลี่ยน ให้เพิ่ม v +0.0001 +// หลังแก้ค่าแล้วต้อง copy ไป path ที่ Nginx ชี้ (หรือรัน copy-frogger-files-only.sh) ถึงจะเห็นบนเว็บ +window.APP_VERSION = '0.0164'; +document.addEventListener('DOMContentLoaded', function () { + var t = document.querySelector('.version-tag'); + if (t) t.textContent = 'v ' + window.APP_VERSION; +}); diff --git a/www/html/Game/public/TryChangeCSS/lobby.html b/www/html/Game/public/TryChangeCSS/lobby.html new file mode 100644 index 0000000..9a24f7a --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/lobby.html @@ -0,0 +1,61 @@ + + + + + + ล็อบบี้ — Game + + + +
+

ล็อบบี้

+

กลับ · เลือกตัวละคร

+

+ +
+

ห้องสาธารณะ (Public) — เลือกเข้าร่วมได้เลย

+
+

โหลดรายการห้อง...

+
    +
    +
    + +
    +

    สร้างห้องใหม่

    +

    เลือกประเภทห้อง แล้วกดสร้าง

    +

    + — โชว์ในรายการด้านบน คนอื่น join ได้เลย +
    + — สร้างรหัสอัตโนมัติ แชร์รหัสเพื่อให้คนอื่นเข้า +

    +

    + +

    + +
    + +
    +

    เข้าร่วมห้องส่วนตัว (Private)

    +

    กรอกรหัสห้องที่ได้จาก host เพื่อเข้า

    +
    + + +
    +
    +
    + + + +
    v —
    + + diff --git a/www/html/Game/public/TryChangeCSS/play.html b/www/html/Game/public/TryChangeCSS/play.html new file mode 100644 index 0000000..2c63aa8 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/play.html @@ -0,0 +1,273 @@ + + + + + + เล่น — Game + + + + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + +
    v —
    + + diff --git a/www/html/Game/public/TryChangeCSS/room-lobby.html b/www/html/Game/public/TryChangeCSS/room-lobby.html new file mode 100644 index 0000000..8b8f3c3 --- /dev/null +++ b/www/html/Game/public/TryChangeCSS/room-lobby.html @@ -0,0 +1,1526 @@ + + + + + + โถงรอ — Game + + + + + + + + + +
    +
    + + อนุญาตให้เข้าถึงไมโครโฟน เพื่อให้คุณสามารถสื่อสารและบันทึกเสียงได้อย่างคมชัด โปรดอนุญาตให้แอปเข้าถึงไมโครโฟนของอุปกรณ์ + +
    +
    + + +
    + + +
    +

    ช่องเขียว · กด F / แป้นไทย โต้ตอบ

    +
    +

    PLAYERS : 0/10

    + +
    +
    + + +
    +
    +
    +
    + ตัวละครของคุณ +
    + +
    +
    +
    + + +
    +
    + Room ID : + + +
    +
    + + +
    +
    +
    +
    +
    + + + + +
    +
    + + + + + +
    + +
    +
    + + +
    + + + + + + + + +
    v —
    + + diff --git a/www/html/Game/public/character.html b/www/html/Game/public/character.html index e03b668..b8cdf32 100644 --- a/www/html/Game/public/character.html +++ b/www/html/Game/public/character.html @@ -348,7 +348,7 @@ - +
    v —
    diff --git a/www/html/Game/public/character.js b/www/html/Game/public/character.js index ad4c85c..7b1f50e 100644 --- a/www/html/Game/public/character.js +++ b/www/html/Game/public/character.js @@ -1,6 +1,38 @@ (function () { const BASE = '/Game'; const STORAGE_KEY = 'gameCharacterId'; + /** Main-Lobby ใช้รูป composite idle ทิศ down แทน _down.png เมื่อมี */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + function persistLobbyIdleDownPreview(characterId, dataUrl) { + const id = String(characterId || '').trim(); + if (!id) return; + try { + if (dataUrl && typeof dataUrl === 'string' && dataUrl.indexOf('data:image/') === 0) { + localStorage.setItem(LOBBY_IDLE_DOWN_LS + id, dataUrl); + } + } catch (e) { + console.warn('Lobby idle preview cache', e); + } + } + + function clearLobbyIdleDownPreview(characterId) { + const id = String(characterId || '').trim(); + if (!id) return; + try { + localStorage.removeItem(LOBBY_IDLE_DOWN_LS + id); + } catch (e) { /* ignore */ } + } + + function charIdForLobbyIdlePersist() { + try { + const sel = (localStorage.getItem(STORAGE_KEY) || '').trim(); + if (sel) return sel; + } catch (e) { /* ignore */ } + const el = document.getElementById('char-name'); + return el ? String(el.value || '').trim() : ''; + } + const grid = document.getElementById('character-grid'); const status = document.getElementById('char-status'); const hint = document.getElementById('char-hint'); @@ -341,6 +373,9 @@ if (dataUrl) { prev.src = dataUrl; prev.title = 'ตัวอย่าง idle เฟรมที่ 1'; + if (dir === 'down') { + persistLobbyIdleDownPreview(charIdForLobbyIdlePersist(), dataUrl); + } } else { prev.src = ''; prev.title = ''; @@ -513,6 +548,7 @@ if (current === char.id) { setSelected(''); } + clearLobbyIdleDownPreview(char.id); refreshList(); }) .catch((err) => { @@ -582,6 +618,13 @@ } if (data.ok) { const savedId = data.characterId || ''; + const idleDownImg = idleLayerPreviews.down; + const idleDownDataUrl = idleDownImg && idleDownImg.src && String(idleDownImg.src).indexOf('data:image/') === 0 + ? idleDownImg.src + : ''; + if (savedId && idleDownDataUrl) { + persistLobbyIdleDownPreview(savedId, idleDownDataUrl); + } if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + savedId; editLayerManifest = null; clearLayerThumbStrips(); diff --git a/www/html/Game/public/js/character.js b/www/html/Game/public/js/character.js index ad4c85c..7b1f50e 100644 --- a/www/html/Game/public/js/character.js +++ b/www/html/Game/public/js/character.js @@ -1,6 +1,38 @@ (function () { const BASE = '/Game'; const STORAGE_KEY = 'gameCharacterId'; + /** Main-Lobby ใช้รูป composite idle ทิศ down แทน _down.png เมื่อมี */ + const LOBBY_IDLE_DOWN_LS = 'jdCharLobbyIdleDown:'; + + function persistLobbyIdleDownPreview(characterId, dataUrl) { + const id = String(characterId || '').trim(); + if (!id) return; + try { + if (dataUrl && typeof dataUrl === 'string' && dataUrl.indexOf('data:image/') === 0) { + localStorage.setItem(LOBBY_IDLE_DOWN_LS + id, dataUrl); + } + } catch (e) { + console.warn('Lobby idle preview cache', e); + } + } + + function clearLobbyIdleDownPreview(characterId) { + const id = String(characterId || '').trim(); + if (!id) return; + try { + localStorage.removeItem(LOBBY_IDLE_DOWN_LS + id); + } catch (e) { /* ignore */ } + } + + function charIdForLobbyIdlePersist() { + try { + const sel = (localStorage.getItem(STORAGE_KEY) || '').trim(); + if (sel) return sel; + } catch (e) { /* ignore */ } + const el = document.getElementById('char-name'); + return el ? String(el.value || '').trim() : ''; + } + const grid = document.getElementById('character-grid'); const status = document.getElementById('char-status'); const hint = document.getElementById('char-hint'); @@ -341,6 +373,9 @@ if (dataUrl) { prev.src = dataUrl; prev.title = 'ตัวอย่าง idle เฟรมที่ 1'; + if (dir === 'down') { + persistLobbyIdleDownPreview(charIdForLobbyIdlePersist(), dataUrl); + } } else { prev.src = ''; prev.title = ''; @@ -513,6 +548,7 @@ if (current === char.id) { setSelected(''); } + clearLobbyIdleDownPreview(char.id); refreshList(); }) .catch((err) => { @@ -582,6 +618,13 @@ } if (data.ok) { const savedId = data.characterId || ''; + const idleDownImg = idleLayerPreviews.down; + const idleDownDataUrl = idleDownImg && idleDownImg.src && String(idleDownImg.src).indexOf('data:image/') === 0 + ? idleDownImg.src + : ''; + if (savedId && idleDownDataUrl) { + persistLobbyIdleDownPreview(savedId, idleDownDataUrl); + } if (uploadStatus) uploadStatus.textContent = 'อัปโหลดสำเร็จ: ' + savedId; editLayerManifest = null; clearLayerThumbStrips(); diff --git a/www/html/Join Room/IMAGE/line.png b/www/html/Join Room/IMAGE/line.png new file mode 100644 index 0000000..4f2fbda Binary files /dev/null and b/www/html/Join Room/IMAGE/line.png differ diff --git a/www/html/Loading/IMAGE/t-01.png b/www/html/Loading/IMAGE/t-01.png index 5226302..4181f5f 100644 Binary files a/www/html/Loading/IMAGE/t-01.png and b/www/html/Loading/IMAGE/t-01.png differ diff --git a/www/html/Loading/IMAGE/t-02.png b/www/html/Loading/IMAGE/t-02.png index b2993f0..a093458 100644 Binary files a/www/html/Loading/IMAGE/t-02.png and b/www/html/Loading/IMAGE/t-02.png differ diff --git a/www/html/Loading/IMAGE/t-03.png b/www/html/Loading/IMAGE/t-03.png index ff8473a..6b568cf 100644 Binary files a/www/html/Loading/IMAGE/t-03.png and b/www/html/Loading/IMAGE/t-03.png differ diff --git a/www/html/Loading/IMAGE/t-04.png b/www/html/Loading/IMAGE/t-04.png index bc1b0ee..a9796c6 100644 Binary files a/www/html/Loading/IMAGE/t-04.png and b/www/html/Loading/IMAGE/t-04.png differ diff --git a/www/html/Loading/IMAGE/t-06.png b/www/html/Loading/IMAGE/t-06.png index 75556a7..10343c0 100644 Binary files a/www/html/Loading/IMAGE/t-06.png and b/www/html/Loading/IMAGE/t-06.png differ diff --git a/www/html/Loading/IMAGE/t-07.png b/www/html/Loading/IMAGE/t-07.png index beebe0c..f069911 100644 Binary files a/www/html/Loading/IMAGE/t-07.png and b/www/html/Loading/IMAGE/t-07.png differ diff --git a/www/html/Main-Lobby/IMAGE/leaderboard-line.png b/www/html/Main-Lobby/IMAGE/leaderboard-line.png new file mode 100644 index 0000000..f8615a6 Binary files /dev/null and b/www/html/Main-Lobby/IMAGE/leaderboard-line.png differ diff --git a/www/html/Main-Lobby/IMAGE/leaderboard-txt-2.png b/www/html/Main-Lobby/IMAGE/leaderboard-txt-2.png new file mode 100644 index 0000000..356164b Binary files /dev/null and b/www/html/Main-Lobby/IMAGE/leaderboard-txt-2.png differ diff --git a/www/html/Main-Lobby/IMAGE/leaderboard-txt.png b/www/html/Main-Lobby/IMAGE/leaderboard-txt.png new file mode 100644 index 0000000..592ac61 Binary files /dev/null and b/www/html/Main-Lobby/IMAGE/leaderboard-txt.png differ diff --git a/www/html/Main-Lobby/index.html b/www/html/Main-Lobby/index.html index acd76c0..e22e4bb 100644 --- a/www/html/Main-Lobby/index.html +++ b/www/html/Main-Lobby/index.html @@ -7,7 +7,7 @@ - +