diff --git a/scripts/deploy-www.sh b/scripts/deploy-www.sh index d524d1f..9fba820 100755 --- a/scripts/deploy-www.sh +++ b/scripts/deploy-www.sh @@ -19,4 +19,8 @@ PLAQUE="$DST/Game/public/img/quiz-carry-plaque-assets" mkdir -p "$PLAQUE" chown www-data:www-data "$PLAQUE" chmod 2775 "$PLAQUE" +GA="$DST/Game/public/img/gauntlet-assets" +mkdir -p "$GA" +chown www-data:www-data "$GA" +chmod 2775 "$GA" echo "OK: deployed $SRC -> $DST (node_modules on server untouched)" diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 4102d59..e723680 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -72,7 +72,7 @@ - + diff --git a/www/html/Game/data/characters/char-1777017632279_down_idle_layer_shadow.png b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_shadow.png new file mode 100644 index 0000000..7ae4407 Binary files /dev/null and b/www/html/Game/data/characters/char-1777017632279_down_idle_layer_shadow.png differ diff --git a/www/html/Game/public/img/gauntlet-assets/.gitkeep b/www/html/Game/public/img/gauntlet-assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/www/html/Game/public/img/gauntlet-assets/gauntlet-077c293962b8fe07.png b/www/html/Game/public/img/gauntlet-assets/gauntlet-077c293962b8fe07.png new file mode 100644 index 0000000..9f41af2 Binary files /dev/null and b/www/html/Game/public/img/gauntlet-assets/gauntlet-077c293962b8fe07.png differ diff --git a/www/html/Game/public/img/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png b/www/html/Game/public/img/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png new file mode 100644 index 0000000..d0a6b7b Binary files /dev/null and b/www/html/Game/public/img/gauntlet-assets/gauntlet-2a75a958cd5e5e8a.png differ diff --git a/www/html/Game/public/img/gauntlet-assets/gauntlet-67a585790f67fe67.png b/www/html/Game/public/img/gauntlet-assets/gauntlet-67a585790f67fe67.png new file mode 100644 index 0000000..366768c Binary files /dev/null and b/www/html/Game/public/img/gauntlet-assets/gauntlet-67a585790f67fe67.png differ diff --git a/www/html/Game/public/img/gauntlet-assets/gauntlet-6aa244ab734b12e4.png b/www/html/Game/public/img/gauntlet-assets/gauntlet-6aa244ab734b12e4.png new file mode 100644 index 0000000..b7b678c Binary files /dev/null and b/www/html/Game/public/img/gauntlet-assets/gauntlet-6aa244ab734b12e4.png differ diff --git a/www/html/Game/public/img/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png b/www/html/Game/public/img/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png new file mode 100644 index 0000000..7eed16e Binary files /dev/null and b/www/html/Game/public/img/gauntlet-assets/gauntlet-c4d4482e2a60eac9.png differ diff --git a/www/html/Game/public/img/quiz-carry/btn-done.png b/www/html/Game/public/img/quiz-carry/btn-done.png new file mode 100644 index 0000000..6ea7618 Binary files /dev/null and b/www/html/Game/public/img/quiz-carry/btn-done.png differ diff --git a/www/html/Game/public/img/quiz-carry/popup-result.png b/www/html/Game/public/img/quiz-carry/popup-result.png new file mode 100644 index 0000000..340a388 Binary files /dev/null and b/www/html/Game/public/img/quiz-carry/popup-result.png differ diff --git a/www/html/Game/public/img/quiz-carry/timeup-txt.png b/www/html/Game/public/img/quiz-carry/timeup-txt.png new file mode 100644 index 0000000..7260662 Binary files /dev/null and b/www/html/Game/public/img/quiz-carry/timeup-txt.png differ diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 13b704f..75f082f 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -13,6 +13,10 @@ const previewMode = params.get('preview') === '1'; const forceDefaultCharacter = params.get('defaultChar') === '1'; const editorEmbedReturn = params.get('editorEmbed') === '1'; + const playMapIdFromQuery = (params.get('map') || '').trim(); + /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */ + const quizCarryMissionSummaryMapId = 'mnorwqx1'; + const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId; /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; let firstCharacterDefaultResolved = null; @@ -159,8 +163,39 @@ return -1; } + /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + function ensureCharacterIdleAnim(id, dir) { const d = dir || 'down'; + /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ + if (!useMultiFrameIdleSpriteSheets(id)) { + const key = String(id) + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } const key = id + '_' + d + '_idle'; let anim = characterIdleAnimations[key]; if (!anim) { @@ -470,13 +505,27 @@ let anyTintColorLayer = false; let skippedShadowWhilePending = false; const diskLayers = playCharDiskLayersSet(id); + /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ + const idleLayerStripes = !!useIdle; + const resDir = dir; + const resFi = frameIndex; for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { const layerName = PLAY_LAYER_ORDER[li]; if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; - /* ห้าม fallback face จากทิศ down เมื่อเป็น up/left/right — จะได้ตาอยู่หลังหัวเพราะวางหน้าหน้าบนร่างหันหลัง */ - const urls = layerName === 'shadow' - ? (useIdle ? shadowUrlCandidatesIdle(id, dir, frameIndex) : shadowUrlCandidates(id, dir, frameIndex)) - : (useIdle ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) : layerUrlCandidates(id, dir, layerName, frameIndex)); + /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ + if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { + continue; + } + let urls; + if (layerName === 'shadow') { + urls = idleLayerStripes + ? shadowUrlCandidatesIdle(id, dir, frameIndex) + : shadowUrlCandidates(id, resDir, resFi); + } else { + urls = idleLayerStripes + ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) + : layerUrlCandidates(id, resDir, layerName, resFi); + } const r = resolvePlayLayerImage(urls); /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ if (r.status === 'pending' && layerName === 'shadow') { @@ -504,24 +553,24 @@ if (playCharLayerDiscoveryPending(characterId)) return rawImg; - /* มี PNG idle รวม — อย่าทับด้วยคอมโพสเลเยอร์จากเฟรมเดิน (idle เป็นภาพรวมแล้ว) */ - if (!isWalking && characterIdleSpritesVisible(characterId, dirn)) { - return rawImg; - } - const apiFlag = playCharLayerFromApi(characterId); /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ const charHasLayerFiles = apiFlag === true || (apiFlag == null && playCharAnyDirectionLayered(characterId)); - /* ct3: ไม่ใช้ face จาก down บนทิศอื่น (แก้ตาหลังหัว) */ + + /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ + if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { + return rawImg; + } const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); if (charHasLayerFiles) { if (!isWalking) { - const cacheKeyIdle = [characterId, dirn, idlePhase, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); + const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; + const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); const hitI = playLayerCompositeCache.get(cacheKeyIdle); if (hitI) return hitI; - const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, idlePhase, tint, { idle: true }); + const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); if (packI && packI.canvas) { if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); return packI.canvas; @@ -559,7 +608,8 @@ const t = typeof now === 'number' ? now : Date.now(); if (!isWalking) { const idleAnim = ensureCharacterIdleAnim(id, dir); - const idlePhase = idleAnimPhaseIndex(t); + let idlePhase = idleAnimPhaseIndex(t); + if (isUploadedCharAssetId(id)) idlePhase = 0; const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); if (idleFi >= 0) return idleAnim.frames[idleFi]; const idfb = idleAnim.fallback; @@ -707,6 +757,8 @@ let quizCarrySessionLength = 0; let quizCarryRoundsCompleted = 0; let quizCarrySessionEnded = false; + /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */ + let quizCarryResultEndTimer = null; /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */ let quizCarryPregameActive = false; let playHostId = null; @@ -1462,6 +1514,72 @@ fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont); } + /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */ + function syncQuizCarryTimeupDeskLayerPosition() { + const layer = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!layer || layer.classList.contains('is-hidden')) return; + const anchor = layer.querySelector('.qc-timeup-desk-anchor') + || layer.querySelector('.qc-timeup-desk-inner') + || (() => { + const img = document.getElementById('quiz-carry-timeup-txt-img'); + return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null; + })(); + if (!anchor || !canvas) return; + const clearToFullStack = () => { + anchor.style.left = '0'; + anchor.style.top = '0'; + anchor.style.width = '100%'; + anchor.style.height = '100%'; + }; + const stack = document.getElementById('play-canvas-stack'); + const panel = document.getElementById('quiz-map-question-panel'); + if (stack && panel && !panel.classList.contains('is-hidden')) { + try { + const sr = stack.getBoundingClientRect(); + const pr = panel.getBoundingClientRect(); + const rw = pr.width; + const rh = pr.height; + if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) { + anchor.style.left = Math.round(pr.left - sr.left) + 'px'; + anchor.style.top = Math.round(pr.top - sr.top) + 'px'; + anchor.style.width = Math.round(rw) + 'px'; + anchor.style.height = Math.round(rh) + 'px'; + return; + } + } catch (e) { /* fallback ด้านล่าง */ } + } + const pl = panel && panel.style && String(panel.style.left || '').trim(); + const pt = panel && panel.style && String(panel.style.top || '').trim(); + const pw = panel && panel.style && String(panel.style.width || '').trim(); + const ph = panel && panel.style && String(panel.style.height || '').trim(); + if (pl && pt && pw && ph) { + anchor.style.left = pl; + anchor.style.top = pt; + anchor.style.width = pw; + anchor.style.height = ph; + return; + } + if (!mapData || mapData.gameType !== 'quiz_carry') { + clearToFullStack(); + return; + } + const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + clearToFullStack(); + return; + } + const camX = me.x * tileSize; + const camY = me.y * tileSize; + const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom; + anchor.style.left = Math.round(left) + 'px'; + anchor.style.top = Math.round(top) + 'px'; + anchor.style.width = Math.round(Math.max(48, wPx)) + 'px'; + anchor.style.height = Math.round(Math.max(40, hPx)) + 'px'; + } + function carryEmbedCountdownAnchorResolved() { if (!mapData || !isQuizCarry()) return 'screen'; const v = mapData.carryEmbedCountdownAnchor; @@ -3288,6 +3406,306 @@ quizCarryPickNextQuestion(); } + function playQuizCarryAvatarUrlForMission(characterId) { + if (!characterId) return ''; + return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; + } + + /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */ + function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) { + if (!imgEl || !characterId) return; + let attempts = 0; + const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId); + const tick = () => { + attempts++; + const nowT = Date.now(); + const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down'); + if (!rawImg) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) { + if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts); + setTimeout(tick, 90); + return; + } + if (!rawImg.naturalWidth) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + const tint = playTintFromPeerId(peerId != null ? String(peerId) : String(characterId)); + const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint); + if (out && out.tagName === 'CANVAS' && out.width > 0) { + try { + imgEl.src = out.toDataURL('image/png'); + return; + } catch (e) { /* ต่อไปลอง walk หรือ URL */ } + } + if (out && out.src && out !== rawImg) { + imgEl.src = out.src; + return; + } + if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) { + setTimeout(tick, 120); + return; + } + imgEl.src = rawImg.src || fallbackUrl; + }; + tick(); + } + + /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */ + function quizCarryGradeFromTeamAverage(avgRounded) { + if (avgRounded >= 80) return 'A'; + if (avgRounded >= 60) return 'B'; + return 'C'; + } + + function quizCarryRollCardRewardForGrade(grade) { + const u = Math.random() * 100; + if (grade === 'A') { + if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'B') { + if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C') { + if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + return null; + } + + function quizCarryBuildMissionRankList() { + const ranks = []; + if (myId != null) { + ranks.push({ + id: myId, + nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', + score: Math.max(0, Number(playLiveQuizScores[myId]) || 0), + characterId: me.characterId || getPlayCharacterId(), + }); + } + others.forEach((o, id) => { + if (!o) return; + ranks.push({ + id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), + score: Math.max(0, Number(playLiveQuizScores[id]) || 0), + characterId: o.characterId || null, + }); + }); + ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); + return ranks.slice(0, 5); + } + + function cancelQuizCarryResultEndAfterTimeup() { + if (quizCarryResultEndTimer != null) { + clearTimeout(quizCarryResultEndTimer); + quizCarryResultEndTimer = null; + } + } + + /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */ + function quizCarryAnyPlayerHasCorrectAnswer() { + if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false; + const keys = Object.keys(playLiveQuizScores); + for (let i = 0; i < keys.length; i++) { + if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true; + } + return false; + } + + function hideQuizCarryResultEndLayer() { + const el = document.getElementById('quiz-carry-result-end-layer'); + if (!el) return; + el.classList.add('is-hidden'); + el.setAttribute('aria-hidden', 'true'); + } + + function showQuizCarryResultEndLayer(useComplete) { + const el = document.getElementById('quiz-carry-result-end-layer'); + const img = document.getElementById('quiz-carry-result-end-img'); + if (!el || !img) return; + const fname = useComplete ? 'result-complete.png' : 'result-gameover.png'; + img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now()); + el.classList.remove('is-hidden'); + el.setAttribute('aria-hidden', 'false'); + } + + /** หลังแสดง timeup บนโต๊ะ (embed): รอ 5 วินาที แล้วโชว์ mock จบตามว่ามีคนตอบถูกหรือไม่ */ + function scheduleQuizCarryResultEndAfterTimeup() { + cancelQuizCarryResultEndAfterTimeup(); + hideQuizCarryResultEndLayer(); + quizCarryResultEndTimer = setTimeout(function () { + quizCarryResultEndTimer = null; + const anyOk = quizCarryAnyPlayerHasCorrectAnswer(); + hideQuizCarryTimeupOnDeskLayer(); + showQuizCarryResultEndLayer(anyOk); + }, 5000); + } + + function hideQuizCarryTimeupOnDeskLayer() { + cancelQuizCarryResultEndAfterTimeup(); + const el = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!el) return; + const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner'); + if (anchor) { + anchor.style.left = ''; + anchor.style.top = ''; + anchor.style.width = ''; + anchor.style.height = ''; + } + el.classList.add('is-hidden'); + el.setAttribute('aria-hidden', 'true'); + } + + /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup-txt กลางโต๊ะบนแคนวาส แยกจากป๊อปสรุปคะแนน */ + function showQuizCarryTimeupOnDeskLayer() { + const el = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!el) return; + const img = document.getElementById('quiz-carry-timeup-txt-img'); + if (img) { + img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); + } + el.classList.remove('is-hidden'); + el.setAttribute('aria-hidden', 'false'); + try { + syncQuizCarryTimeupDeskLayerPosition(); + } catch (e) { /* ignore */ } + if (previewMode && editorEmbedReturn) { + scheduleQuizCarryResultEndAfterTimeup(); + } + } + + function showQuizCarryMissionSummaryOverlay(opts) { + opts = opts || {}; + const forceF = !!opts.forceGradeF; + const overlay = document.getElementById('quiz-carry-mission-overlay'); + if (!overlay) return; + hideQuizCarryTimeupOnDeskLayer(); + const ranks = quizCarryBuildMissionRankList(); + const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0)); + const total = parts.reduce((a, b) => a + b, 0); + const n = ranks.length; + const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0; + const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade); + const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade); + + const rowEl = document.getElementById('qc-mission-rank-row'); + const totalEl = document.getElementById('qc-mission-total-line'); + const gradeEl = document.getElementById('qc-mission-grade'); + const cardTextEl = document.getElementById('qc-mission-card-text'); + const checkEl = document.getElementById('qc-mission-check'); + const successTxt = document.getElementById('qc-mission-success-txt'); + const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]']; + if (rowEl) { + rowEl.innerHTML = ''; + ranks.forEach((r, idx) => { + const rank = idx + 1; + const cell = document.createElement('div'); + cell.className = 'qc-mission-rank-cell'; + const tag = document.createElement('div'); + tag.className = 'qc-mission-rank-tag'; + tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']'); + const frame = document.createElement('div'); + frame.className = 'qc-mission-rank-frame'; + const img = document.createElement('img'); + img.className = 'qc-mission-rank-avatar'; + img.alt = ''; + applyQuizCarryRankAvatarTint(img, r.characterId, r.id); + img.onerror = function () { + this.onerror = null; + this.src = 'data:image/svg+xml,' + encodeURIComponent('?'); + }; + frame.appendChild(img); + const nick = document.createElement('div'); + nick.className = 'qc-mission-rank-nick'; + nick.textContent = r.nickname; + nick.title = r.nickname; + const sc = document.createElement('div'); + sc.className = 'qc-mission-rank-score'; + sc.textContent = String(r.score); + cell.appendChild(tag); + cell.appendChild(frame); + cell.appendChild(nick); + cell.appendChild(sc); + rowEl.appendChild(cell); + }); + } + if (totalEl) { + totalEl.textContent = ''; + if (!n) { + totalEl.textContent = '—'; + } else { + const lab = document.createElement('span'); + lab.textContent = 'คะแนนรวม '; + const nums = document.createElement('span'); + nums.className = 'qc-mission-total-nums'; + nums.textContent = '(' + parts.join('+') + ') = ' + total; + const tail = document.createElement('span'); + tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100'; + totalEl.appendChild(lab); + totalEl.appendChild(nums); + totalEl.appendChild(tail); + } + } + if (gradeEl) { + gradeEl.textContent = grade; + gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase(); + } + const okMission = grade !== 'F'; + if (checkEl) checkEl.style.display = okMission ? '' : 'none'; + if (successTxt) { + successTxt.style.display = okMission ? '' : 'none'; + if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ'; + } + if (cardTextEl) { + cardTextEl.textContent = ''; + if (grade === 'F') { + const pl = document.createElement('div'); + pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted'; + const t = document.createElement('div'); + t.className = 'qc-mission-reward-plaque-title'; + t.textContent = 'ไม่ได้รับการ์ด'; + const s = document.createElement('div'); + s.className = 'qc-mission-reward-plaque-sub'; + s.textContent = 'เกรด F'; + pl.appendChild(t); + pl.appendChild(s); + cardTextEl.appendChild(pl); + } else if (reward) { + const pl = document.createElement('div'); + pl.className = 'qc-mission-reward-plaque'; + const t = document.createElement('div'); + t.className = 'qc-mission-reward-plaque-title'; + t.textContent = reward.th; + const s = document.createElement('div'); + s.className = 'qc-mission-reward-plaque-sub'; + s.textContent = reward.en; + pl.appendChild(t); + pl.appendChild(s); + cardTextEl.appendChild(pl); + } + } + overlay.classList.remove('is-hidden'); + const btn = document.getElementById('btn-quiz-carry-mission-done'); + if (btn) { + btn.onclick = function () { + overlay.classList.add('is-hidden'); + if (previewMode && editorEmbedReturn) { + showQuizCarryTimeupOnDeskLayer(); + } else { + window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + } + }; + } + } + function showQuizCarrySessionCompleteOverlay() { if (quizCarrySessionEnded) return; quizCarrySessionEnded = true; @@ -3311,6 +3729,14 @@ if (qEl) qEl.textContent = playQuizText; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ'; + const missionOv = document.getElementById('quiz-carry-mission-overlay'); + if (missionOv && quizCarryUseMissionSummaryOverlay) { + showQuizCarryMissionSummaryOverlay({ + forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated), + }); + renderPlayQuizScoreboard(playLiveQuizScores); + return; + } const ov = document.getElementById('gauntlet-ended-overlay'); const msgEl = document.getElementById('gauntlet-ended-message'); const titleEl = document.getElementById('gauntlet-ended-title'); @@ -3547,6 +3973,8 @@ quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; hideQuizCarryEmbedCountdownOverlay(); + hideQuizCarryTimeupOnDeskLayer(); + hideQuizCarryResultEndLayer(); me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; @@ -3593,6 +4021,8 @@ async function loadQuizCarryPoolAndStart() { quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; + hideQuizCarryTimeupOnDeskLayer(); + hideQuizCarryResultEndLayer(); quizCarrySessionLength = 0; let pool = []; let s = {}; @@ -8639,9 +9069,25 @@ if (portrait) { const cid = me.characterId || getPlayCharacterId(); - const fr = getCharacterFrame(cid, me.direction || 'down', Date.now(), !!me.isWalking); - const fb = fr && fr.src ? fr : getCharacterImg(cid, 'down'); - if (fb && fb.src) portrait.src = fb.src; + const dir = me.direction || 'down'; + const nowT = Date.now(); + const walk = !!me.isWalking; + const rawImg = getAvatarImg(cid, dir, nowT, walk); + const tint = me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me')); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { + try { + portrait.src = comp.toDataURL('image/png'); + } catch (e) { + if (rawImg && rawImg.src) portrait.src = rawImg.src; + } + } else if (comp && comp.src) { + portrait.src = comp.src; + } else if (rawImg && rawImg.src) { + portrait.src = rawImg.src; + } } if (statusEl) { if (isQuizCarry() && quizCarrySessionEnded) { @@ -9509,6 +9955,8 @@ syncPlayQuizMapPanel(); syncQuizCarryEmbedQuestionStrip(); } + /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */ + syncQuizCarryTimeupDeskLayerPosition(); if (previewFillBots && mapData && !useCyberPlayHud()) { const human = countPlayHumans(); const bots = [...others.keys()].filter(isPreviewBotId).length; diff --git a/www/html/Game/public/js/room-lobby.js b/www/html/Game/public/js/room-lobby.js index 444fec8..1280260 100644 --- a/www/html/Game/public/js/room-lobby.js +++ b/www/html/Game/public/js/room-lobby.js @@ -266,8 +266,36 @@ return -1; } + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + function ensureCharacterIdleAnim(id, dir) { const d = dir || 'down'; + if (!useMultiFrameIdleSpriteSheets(id)) { + const key0 = String(id) + '_' + d + '_idle'; + var anim0 = characterIdleAnimations[key0]; + if (!anim0) { + anim0 = { frames: [], fallback: null }; + characterIdleAnimations[key0] = anim0; + var enc0 = encodeURIComponent(id); + for (var ii = 0; ii < CHARACTER_ANIM_FRAMES; ii++) { + var im0 = new Image(); + im0.src = BASE + '/img/characters/' + enc0 + '_' + d + '_' + ii + '.png'; + anim0.frames.push(im0); + } + anim0.fallback = new Image(); + anim0.fallback.src = BASE + '/img/characters/' + enc0 + '_' + d + '_idle.png'; + } + return anim0; + } const key = id + '_' + d + '_idle'; var anim = characterIdleAnimations[key]; if (!anim) { diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 3301b8f..f6a53f6 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -27,6 +27,78 @@ display: block; touch-action: none; } + /* หลังกดรับหลักฐานในหน้าสรุป — BG ดำ alpha ทั้งจอ + timeup ติดโซนโต๊ะเหมือน #quiz-map-question-panel */ + #quiz-carry-timeup-desk-layer { + position: absolute; + inset: 0; + z-index: 65; + pointer-events: none; + box-sizing: border-box; + } + #quiz-carry-timeup-desk-layer.is-hidden { + display: none !important; + } + .qc-timeup-dim { + position: absolute; + inset: 0; + z-index: 0; + background: rgba(0, 0, 0, 0.58); + pointer-events: none; + } + .qc-timeup-desk-anchor { + position: absolute; + z-index: 1; + box-sizing: border-box; + padding: clamp(6px, 1.5vw, 14px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + overflow: visible; + pointer-events: none; + } + .qc-timeup-txt-img { + width: min(94%, 520px); + max-width: 100%; + height: auto; + object-fit: contain; + display: block; + filter: drop-shadow(0 10px 36px rgba(0, 0, 0, 0.65)) drop-shadow(0 0 20px rgba(255, 60, 80, 0.15)); + } + /* หลัง timeup 5s — mock จบ: result-complete / result-gameover เต็มพื้นที่แคนวาส */ + #quiz-carry-result-end-layer { + position: absolute; + inset: 0; + z-index: 66; + pointer-events: none; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + #quiz-carry-result-end-layer.is-hidden { + display: none !important; + } + .qc-result-end-dim { + position: absolute; + inset: 0; + z-index: 0; + background: rgba(0, 0, 0, 0.62); + pointer-events: none; + } + .qc-result-end-img { + position: relative; + z-index: 1; + width: min(96vw, 920px); + max-height: min(88vh, 640px); + height: auto; + object-fit: contain; + display: block; + pointer-events: none; + filter: drop-shadow(0 12px 40px rgba(0, 0, 0, 0.7)); + } /* Quiz carry — ปุ่ม GRAB มุมขวาล่าง (เทียบ F) · mockup ≈ 10–12% ความสูงจอ */ #quiz-carry-grab-btn { position: absolute; @@ -526,6 +598,299 @@ border: 2px solid rgba(247, 118, 140, 0.85); color: #fecdd3; } + /* quiz_carry — สรุปผลภารกิจ (mock) · พื้นหลังแยกจากเนื้อหา กันลาย PNG [4th]/[5th] ทับ + หัวซ้ำกับ artwork */ + #quiz-carry-mission-overlay { + position: fixed; + inset: 0; + z-index: 10090; + display: flex; + align-items: center; + justify-content: center; + padding: max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); + box-sizing: border-box; + font-family: 'Orbitron', 'Segoe UI', sans-serif; + } + #quiz-carry-mission-overlay.is-hidden { display: none !important; } + .qc-mission-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 6, 14, 0.78); + backdrop-filter: blur(8px); + } + .qc-mission-panel { + position: relative; + width: min(96vw, 720px); + max-width: 100%; + max-height: min(92vh, 860px); + border-radius: 12px; + overflow: hidden; + filter: drop-shadow(0 0 22px rgba(0, 255, 240, 0.18)); + box-sizing: border-box; + display: flex; + flex-direction: column; + } + .qc-mission-panel-bg { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + height: auto; + min-height: 100%; + background-image: url('/Game/img/quiz-carry/popup-result.png'); + background-repeat: no-repeat; + background-position: center top; + background-size: 100% auto; + pointer-events: none; + z-index: 0; + } + .qc-mission-panel-inner { + position: relative; + z-index: 1; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + align-items: stretch; + overflow-x: hidden; + overflow-y: auto; + /* เว้นตำแหน่งหัวข้อใน artwork — ไม่ซ้ำกับ

sr-only */ + padding: clamp(72px, 16vw, 112px) clamp(14px, 3.2vw, 26px) clamp(14px, 2.8vw, 22px); + gap: clamp(8px, 1.6vw, 14px); + } + .qc-mission-rank-row { + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: stretch; + gap: clamp(6px, 1.5vw, 14px); + flex: 0 0 auto; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding: 2px 0; + } + .qc-mission-rank-cell { + flex: 0 0 auto; + width: clamp(68px, 16vw, 104px); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; + color: #e2e8f0; + min-width: 0; + } + /* แท็กอันดับ mock: [1st] … [5th] สีทอง — อยู่เหนือกรอบ ไม่ทับอวาตาร์ */ + .qc-mission-rank-tag { + flex: 0 0 auto; + font-size: clamp(0.5rem, 1.35vw, 0.65rem); + font-weight: 800; + letter-spacing: 0.04em; + color: #f6e08a; + text-shadow: 0 0 10px rgba(246, 224, 138, 0.45); + line-height: 1.1; + white-space: nowrap; + } + .qc-mission-rank-frame { + position: relative; + width: 100%; + max-width: 88px; + aspect-ratio: 1; + margin: 0 auto; + overflow: hidden; + border-radius: 8px; + border: 1px solid rgba(0, 255, 240, 0.45); + box-shadow: + 0 0 0 1px rgba(255, 79, 180, 0.2), + 0 0 14px rgba(0, 255, 240, 0.18); + background: rgba(10, 12, 24, 0.95); + flex-shrink: 0; + } + .qc-mission-rank-avatar { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 72%; + height: 72%; + object-fit: contain; + image-rendering: pixelated; + display: block; + pointer-events: none; + } + .qc-mission-rank-nick { + font-size: clamp(0.52rem, 1.45vw, 0.68rem); + font-weight: 700; + color: #e2e8f0; + line-height: 1.15; + width: 100%; + max-width: 100%; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + hyphens: auto; + } + .qc-mission-rank-score { + font-size: clamp(0.62rem, 1.85vw, 0.78rem); + font-weight: 800; + color: #f6e08a; + text-shadow: 0 0 10px rgba(246, 224, 138, 0.35); + } + .qc-mission-total { + margin: 0; + text-align: center; + font-size: clamp(0.65rem, 1.85vw, 0.8rem); + font-weight: 700; + line-height: 1.4; + flex: 0 0 auto; + color: #f1f5f9; + } + .qc-mission-total-nums { + color: #f6e08a; + font-weight: 800; + text-shadow: 0 0 8px rgba(246, 224, 138, 0.25); + } + .qc-mission-bottom { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); + gap: clamp(10px, 2.2vw, 20px); + align-items: start; + flex: 0 0 auto; + isolation: isolate; + } + @media (max-width: 520px) { + .qc-mission-bottom { grid-template-columns: 1fr; } + } + .qc-mission-section-h { + margin: 0 0 8px; + font-size: clamp(0.68rem, 1.9vw, 0.82rem); + font-weight: 700; + letter-spacing: 0.06em; + color: #f1f5f9; + text-align: center; + } + .qc-mission-grade-block, + .qc-mission-bonus-block { + text-align: center; + min-width: 0; + } + .qc-mission-grade { + font-size: clamp(2.4rem, 11vw, 4rem); + font-weight: 900; + line-height: 1; + letter-spacing: 0.02em; + color: #f8fafc; + text-shadow: 0 0 28px rgba(255, 255, 255, 0.35); + } + .qc-mission-grade--a { color: #9ece6a; text-shadow: 0 0 24px rgba(158, 206, 106, 0.55); } + .qc-mission-grade--b { color: #7aa2f7; text-shadow: 0 0 24px rgba(122, 162, 247, 0.5); } + .qc-mission-grade--c { color: #e0af68; text-shadow: 0 0 22px rgba(224, 175, 104, 0.45); } + .qc-mission-grade--f { color: #f7768e; text-shadow: 0 0 22px rgba(247, 118, 140, 0.45); } + .qc-mission-bonus-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: clamp(10px, 2.5vw, 18px); + min-height: 0; + } + .qc-mission-bonus-inline { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: nowrap; + flex: 0 0 auto; + } + .qc-mission-reward-plaque { + flex: 0 1 auto; + min-width: 100px; + max-width: 160px; + padding: 8px 10px 10px; + border-radius: 10px; + border: 1px solid rgba(0, 255, 240, 0.55); + box-shadow: 0 0 14px rgba(0, 255, 240, 0.15), inset 0 0 20px rgba(0, 40, 48, 0.35); + background: linear-gradient(165deg, rgba(14, 18, 36, 0.98), rgba(8, 10, 22, 0.99)); + text-align: center; + } + .qc-mission-reward-plaque--muted { + border-color: rgba(148, 153, 168, 0.4); + box-shadow: none; + opacity: 0.92; + } + .qc-mission-reward-plaque-title { + font-size: clamp(0.68rem, 1.8vw, 0.8rem); + font-weight: 800; + color: #f1f5f9; + line-height: 1.25; + } + .qc-mission-reward-plaque-sub { + margin-top: 4px; + font-size: clamp(0.58rem, 1.45vw, 0.68rem); + font-weight: 600; + color: #7af8ff; + opacity: 0.9; + } + .qc-mission-check { + width: clamp(22px, 6vw, 32px); + height: auto; + flex-shrink: 0; + } + .qc-mission-success-txt { + font-size: clamp(0.72rem, 2.1vw, 0.88rem); + font-weight: 700; + color: #9ece6a; + } + .qc-mission-card-text { + flex: 0 1 auto; + text-align: center; + font-size: clamp(0.68rem, 1.9vw, 0.82rem); + color: #c0caf5; + line-height: 1.45; + min-width: 0; + max-width: min(200px, 42vw); + } + .qc-mission-reward-sub { + font-size: 0.78em; + opacity: 0.85; + font-weight: 600; + } + .qc-mission-panel-inner #btn-quiz-carry-mission-done { + flex: 0 0 auto; + align-self: center; + } + #btn-quiz-carry-mission-done { + margin: clamp(10px, 2.5vw, 16px) auto 0; + display: block; + width: min(92%, 320px); + height: clamp(44px, 11vw, 56px); + border: none; + padding: 0; + cursor: pointer; + background: url('/Game/img/quiz-carry/btn-done.png') center / contain no-repeat; + background-color: transparent; + } + #btn-quiz-carry-mission-done:focus-visible { + outline: 2px solid rgba(0, 255, 240, 0.7); + outline-offset: 3px; + } + .qc-mission-sr { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + clip-path: inset(50%) !important; + border: 0 !important; + white-space: nowrap !important; + } + #gauntlet-ended-overlay { position: fixed; inset: 0; @@ -966,6 +1331,16 @@ + + + - +
v —
diff --git a/www/html/Game/public/play.js b/www/html/Game/public/play.js index ae7667f..75f082f 100644 --- a/www/html/Game/public/play.js +++ b/www/html/Game/public/play.js @@ -13,6 +13,10 @@ const previewMode = params.get('preview') === '1'; const forceDefaultCharacter = params.get('defaultChar') === '1'; const editorEmbedReturn = params.get('editorEmbed') === '1'; + const playMapIdFromQuery = (params.get('map') || '').trim(); + /** สรุปภารกิจแบบ mock (popup-result ฯลฯ) — ใช้เฉพาะฉากนี้จาก editor (id ใน URL เช่น editor.html?id=mnorwqx1) */ + const quizCarryMissionSummaryMapId = 'mnorwqx1'; + const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId; /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; let firstCharacterDefaultResolved = null; @@ -159,8 +163,39 @@ return -1; } + /** ตัวละครอัปโหลดจากระบบมักเป็น char- + ตัวเลข (อาจมี suffix) — ไม่มี idle strip / idle layer ครบทุกทิศ */ + function isUploadedCharAssetId(id) { + if (!id) return false; + const s = String(id).trim(); + return /^char-\d/i.test(s); + } + + function useMultiFrameIdleSpriteSheets(id) { + if (!id) return true; + return !isUploadedCharAssetId(id); + } + function ensureCharacterIdleAnim(id, dir) { const d = dir || 'down'; + /* char-* ไม่มี *_idle_0.png — ใช้ strip เดิน dir_0..3 เป็น idle ต่อทิศ (มีลูป) แทนรูปเดียวที่มักไม่มี */ + if (!useMultiFrameIdleSpriteSheets(id)) { + const key = String(id) + '_' + d + '_idle'; + let anim = characterIdleAnimations[key]; + if (!anim) { + anim = { frames: [], fallback: null }; + characterIdleAnimations[key] = anim; + const enc = encodeURIComponent(id); + for (let i = 0; i < CHARACTER_ANIM_FRAMES; i++) { + const img = new Image(); + img.src = BASE + '/img/characters/' + enc + '_' + d + '_' + i + '.png'; + anim.frames.push(img); + } + const fb = new Image(); + fb.src = BASE + '/img/characters/' + enc + '_' + d + '_idle.png'; + anim.fallback = fb; + } + return anim; + } const key = id + '_' + d + '_idle'; let anim = characterIdleAnimations[key]; if (!anim) { @@ -179,6 +214,7 @@ return anim; } + /** ลูป idle ขณะยืน — ใช้จังหวะเวลาเหมือนเดิน (CHARACTER_ANIM_FRAME_MS) */ function idleAnimPhaseIndex(now) { const t = typeof now === 'number' ? now : Date.now(); return Math.floor(t / CHARACTER_ANIM_FRAME_MS) % CHARACTER_ANIM_FRAMES; @@ -357,6 +393,7 @@ return out; } + /** เลเยอร์ idle: id__idle__layer_.png หรือ id__idle_layer_.png */ function layerUrlCandidatesIdle(id, dir, layerName, frameIndex) { const enc = encodeURIComponent(id); const base = BASE + '/img/characters/' + enc + '_' + dir + '_idle'; @@ -468,13 +505,27 @@ let anyTintColorLayer = false; let skippedShadowWhilePending = false; const diskLayers = playCharDiskLayersSet(id); + /* ยืน (opts.idle): ลอง compose จาก *_idle_*_layer_* เหมือนหน้า character upload — รวม char-* ที่อัปโหลด idle เลเยอร์; ถ้าไม่มีไฟล์จะ missing → ไม่มีเลเยอร์ย้อม → getPlayTintedAvatarSource fallback ไป walk compose */ + const idleLayerStripes = !!useIdle; + const resDir = dir; + const resFi = frameIndex; for (let li = 0; li < PLAY_LAYER_ORDER.length; li++) { const layerName = PLAY_LAYER_ORDER[li]; if (diskLayers && layerName !== 'shadow' && !diskLayers.has(layerName)) continue; - /* ห้าม fallback face จากทิศ down เมื่อเป็น up/left/right — จะได้ตาอยู่หลังหัวเพราะวางหน้าหน้าบนร่างหันหลัง */ - const urls = layerName === 'shadow' - ? (useIdle ? shadowUrlCandidatesIdle(id, dir, frameIndex) : shadowUrlCandidates(id, dir, frameIndex)) - : (useIdle ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) : layerUrlCandidates(id, dir, layerName, frameIndex)); + /* char-* หันหลัง (up): ไม่มี face ทิศนี้ — ข้ามกันตาหน้าซ้อนหลังหัว; ซ้าย/ขวา/หน้าใช้ face ตามทิศได้ */ + if (layerName === 'face' && isUploadedCharAssetId(id) && dir === 'up') { + continue; + } + let urls; + if (layerName === 'shadow') { + urls = idleLayerStripes + ? shadowUrlCandidatesIdle(id, dir, frameIndex) + : shadowUrlCandidates(id, resDir, resFi); + } else { + urls = idleLayerStripes + ? layerUrlCandidatesIdle(id, dir, layerName, frameIndex) + : layerUrlCandidates(id, resDir, layerName, resFi); + } const r = resolvePlayLayerImage(urls); /* เงาโหลดช้า/404 บ่อย — อย่าให้บล็อกทั้งคอมโพส (ไม่งั้นตกไป fallback แถบแนวตั้งแทนเลเยอร์จริง) */ if (r.status === 'pending' && layerName === 'shadow') { @@ -502,23 +553,24 @@ if (playCharLayerDiscoveryPending(characterId)) return rawImg; - if (!isWalking && characterIdleSpritesVisible(characterId, dirn)) { - return rawImg; - } - const apiFlag = playCharLayerFromApi(characterId); /* เหมือนเดิม: มีเลเยอร์แยกไฟล์เมื่อ API บอก hasLayerFiles หรือ probe เจอ — gen สีทำบน canvas จาก *_layer_* เท่านั้น */ const charHasLayerFiles = apiFlag === true || (apiFlag == null && playCharAnyDirectionLayered(characterId)); - /* ct3: ไม่ใช้ face จาก down บนทิศอื่น (แก้ตาหลังหัว) */ + + /* มี PNG idle รวม — ใช้เมื่อไม่มีระบบเลเยอร์สี (ถ้ามีเลเยอร์ต้อง compose ไม่ return raw ขาว) */ + if (!charHasLayerFiles && !isWalking && characterIdleSpritesVisible(characterId, dirn)) { + return rawImg; + } const cacheKeyWalk = [characterId, dirn, frameIndexWalk, tint.head, tint.hair, tint.body, 'ct3'].join('|'); if (charHasLayerFiles) { if (!isWalking) { - const cacheKeyIdle = [characterId, dirn, idlePhase, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); + const composeFrame = isUploadedCharAssetId(characterId) ? 0 : idlePhase; + const cacheKeyIdle = [characterId, dirn, composeFrame, tint.head, tint.hair, tint.body, 'ct3', 'idleL'].join('|'); const hitI = playLayerCompositeCache.get(cacheKeyIdle); if (hitI) return hitI; - const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, idlePhase, tint, { idle: true }); + const packI = tryComposePlayLayersFromFiles(rawImg, characterId, dirn, composeFrame, tint, { idle: true }); if (packI && packI.canvas) { if (!packI.skipCache) playLayerCompositeCache.set(cacheKeyIdle, packI.canvas); return packI.canvas; @@ -556,7 +608,8 @@ const t = typeof now === 'number' ? now : Date.now(); if (!isWalking) { const idleAnim = ensureCharacterIdleAnim(id, dir); - const idlePhase = idleAnimPhaseIndex(t); + let idlePhase = idleAnimPhaseIndex(t); + if (isUploadedCharAssetId(id)) idlePhase = 0; const idleFi = pickLoadedWalkFrameIndex(idleAnim, idlePhase); if (idleFi >= 0) return idleAnim.frames[idleFi]; const idfb = idleAnim.fallback; @@ -704,6 +757,8 @@ let quizCarrySessionLength = 0; let quizCarryRoundsCompleted = 0; let quizCarrySessionEnded = false; + /** หลัง timeup บนโต๊ะ (embed): รอ 5s แล้วโชว์ result-complete / result-gameover */ + let quizCarryResultEndTimer = null; /** embed พรีวิว: รอ Ready / START ของโฮสต์ก่อนนับ 3–2–1 */ let quizCarryPregameActive = false; let playHostId = null; @@ -1459,6 +1514,72 @@ fitQuizMapQuestionFontToPanel(panel, textEl, startFontPx, mnFont); } + /** โซนเดียวกับแผงคำถามบนแผนที่ — ยึด timeup ให้ตรงกล่อง #quiz-map-question-panel (ห้ามคำนวณซ้ำแล้วคลาด) */ + function syncQuizCarryTimeupDeskLayerPosition() { + const layer = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!layer || layer.classList.contains('is-hidden')) return; + const anchor = layer.querySelector('.qc-timeup-desk-anchor') + || layer.querySelector('.qc-timeup-desk-inner') + || (() => { + const img = document.getElementById('quiz-carry-timeup-txt-img'); + return img && img.parentElement && img.parentElement !== layer ? img.parentElement : null; + })(); + if (!anchor || !canvas) return; + const clearToFullStack = () => { + anchor.style.left = '0'; + anchor.style.top = '0'; + anchor.style.width = '100%'; + anchor.style.height = '100%'; + }; + const stack = document.getElementById('play-canvas-stack'); + const panel = document.getElementById('quiz-map-question-panel'); + if (stack && panel && !panel.classList.contains('is-hidden')) { + try { + const sr = stack.getBoundingClientRect(); + const pr = panel.getBoundingClientRect(); + const rw = pr.width; + const rh = pr.height; + if (rw >= 8 && rh >= 8 && sr.width > 0 && sr.height > 0) { + anchor.style.left = Math.round(pr.left - sr.left) + 'px'; + anchor.style.top = Math.round(pr.top - sr.top) + 'px'; + anchor.style.width = Math.round(rw) + 'px'; + anchor.style.height = Math.round(rh) + 'px'; + return; + } + } catch (e) { /* fallback ด้านล่าง */ } + } + const pl = panel && panel.style && String(panel.style.left || '').trim(); + const pt = panel && panel.style && String(panel.style.top || '').trim(); + const pw = panel && panel.style && String(panel.style.width || '').trim(); + const ph = panel && panel.style && String(panel.style.height || '').trim(); + if (pl && pt && pw && ph) { + anchor.style.left = pl; + anchor.style.top = pt; + anchor.style.width = pw; + anchor.style.height = ph; + return; + } + if (!mapData || mapData.gameType !== 'quiz_carry') { + clearToFullStack(); + return; + } + const bounds = getQuizCarryQuestionAreaTileBounds(mapData) || getQuizCarryHubTileBounds(mapData); + if (!bounds) { + clearToFullStack(); + return; + } + const camX = me.x * tileSize; + const camY = me.y * tileSize; + const left = (bounds.minX * tileSize - camX) * zoom + canvas.width / 2; + const top = (bounds.minY * tileSize - camY) * zoom + canvas.height / 2; + const wPx = (bounds.maxX - bounds.minX + 1) * tileSize * zoom; + const hPx = (bounds.maxY - bounds.minY + 1) * tileSize * zoom; + anchor.style.left = Math.round(left) + 'px'; + anchor.style.top = Math.round(top) + 'px'; + anchor.style.width = Math.round(Math.max(48, wPx)) + 'px'; + anchor.style.height = Math.round(Math.max(40, hPx)) + 'px'; + } + function carryEmbedCountdownAnchorResolved() { if (!mapData || !isQuizCarry()) return 'screen'; const v = mapData.carryEmbedCountdownAnchor; @@ -3285,6 +3406,306 @@ quizCarryPickNextQuestion(); } + function playQuizCarryAvatarUrlForMission(characterId) { + if (!characterId) return ''; + return BASE + '/img/characters/' + encodeURIComponent(String(characterId)) + '_down.png'; + } + + /** อวาตาร์สรุปภารกิจ / DOM — ใช้สี gen เดียวกับในเกม (เลเยอร์ + tint) */ + function applyQuizCarryRankAvatarTint(imgEl, characterId, peerId) { + if (!imgEl || !characterId) return; + let attempts = 0; + const fallbackUrl = playQuizCarryAvatarUrlForMission(characterId); + const tick = () => { + attempts++; + const nowT = Date.now(); + const rawImg = getCharacterFrame(characterId, 'down', nowT, false) || getCharacterImg(characterId, 'down'); + if (!rawImg) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + if ((!rawImg.complete || !rawImg.naturalWidth) && attempts < 28) { + if (fallbackUrl) imgEl.src = fallbackUrl + '?p=' + String(attempts); + setTimeout(tick, 90); + return; + } + if (!rawImg.naturalWidth) { + if (fallbackUrl) imgEl.src = fallbackUrl; + return; + } + const tint = playTintFromPeerId(peerId != null ? String(peerId) : String(characterId)); + const out = getPlayTintedAvatarSource(rawImg, characterId, 'down', nowT, false, tint); + if (out && out.tagName === 'CANVAS' && out.width > 0) { + try { + imgEl.src = out.toDataURL('image/png'); + return; + } catch (e) { /* ต่อไปลอง walk หรือ URL */ } + } + if (out && out.src && out !== rawImg) { + imgEl.src = out.src; + return; + } + if (attempts < 28 && playCharLayerDiscoveryPending(characterId)) { + setTimeout(tick, 120); + return; + } + imgEl.src = rawImg.src || fallbackUrl; + }; + tick(); + } + + /** เกรดจากค่าเฉลี่ยคะแนนทีม (0–100 หลังปัด) — A 80–100, B 60–79, C 0–59 */ + function quizCarryGradeFromTeamAverage(avgRounded) { + if (avgRounded >= 80) return 'A'; + if (avgRounded >= 60) return 'B'; + return 'C'; + } + + function quizCarryRollCardRewardForGrade(grade) { + const u = Math.random() * 100; + if (grade === 'A') { + if (u < 30) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 80) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'B') { + if (u < 20) return { th: 'การ์ดชี้คนร้าย', en: 'Culprit hint card' }; + if (u < 50) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C') { + if (u < 20) return { th: 'การ์ด Rare', en: 'Rare card' }; + return { th: 'การ์ดธรรมดา', en: 'Common card' }; + } + return null; + } + + function quizCarryBuildMissionRankList() { + const ranks = []; + if (myId != null) { + ranks.push({ + id: myId, + nickname: (me.nickname || nick || 'คุณ').trim() || 'คุณ', + score: Math.max(0, Number(playLiveQuizScores[myId]) || 0), + characterId: me.characterId || getPlayCharacterId(), + }); + } + others.forEach((o, id) => { + if (!o) return; + ranks.push({ + id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : String(id), + score: Math.max(0, Number(playLiveQuizScores[id]) || 0), + characterId: o.characterId || null, + }); + }); + ranks.sort((a, b) => b.score - a.score || String(a.nickname).localeCompare(String(b.nickname), 'th')); + return ranks.slice(0, 5); + } + + function cancelQuizCarryResultEndAfterTimeup() { + if (quizCarryResultEndTimer != null) { + clearTimeout(quizCarryResultEndTimer); + quizCarryResultEndTimer = null; + } + } + + /** มีผู้เล่นอย่างน้อยหนึ่งคนได้คะแนน > 0 (= ส่งคำตอบถูกที่ฮับอย่างน้อย 1 ครั้ง; คะแนนต่อข้อคงที่ QUIZ_CARRY_POINTS_PER_CORRECT) */ + function quizCarryAnyPlayerHasCorrectAnswer() { + if (!playLiveQuizScores || typeof playLiveQuizScores !== 'object') return false; + const keys = Object.keys(playLiveQuizScores); + for (let i = 0; i < keys.length; i++) { + if (Math.max(0, Number(playLiveQuizScores[keys[i]]) || 0) > 0) return true; + } + return false; + } + + function hideQuizCarryResultEndLayer() { + const el = document.getElementById('quiz-carry-result-end-layer'); + if (!el) return; + el.classList.add('is-hidden'); + el.setAttribute('aria-hidden', 'true'); + } + + function showQuizCarryResultEndLayer(useComplete) { + const el = document.getElementById('quiz-carry-result-end-layer'); + const img = document.getElementById('quiz-carry-result-end-img'); + if (!el || !img) return; + const fname = useComplete ? 'result-complete.png' : 'result-gameover.png'; + img.src = BASE + '/img/quiz-carry/' + fname + '?v=' + String(Date.now()); + el.classList.remove('is-hidden'); + el.setAttribute('aria-hidden', 'false'); + } + + /** หลังแสดง timeup บนโต๊ะ (embed): รอ 5 วินาที แล้วโชว์ mock จบตามว่ามีคนตอบถูกหรือไม่ */ + function scheduleQuizCarryResultEndAfterTimeup() { + cancelQuizCarryResultEndAfterTimeup(); + hideQuizCarryResultEndLayer(); + quizCarryResultEndTimer = setTimeout(function () { + quizCarryResultEndTimer = null; + const anyOk = quizCarryAnyPlayerHasCorrectAnswer(); + hideQuizCarryTimeupOnDeskLayer(); + showQuizCarryResultEndLayer(anyOk); + }, 5000); + } + + function hideQuizCarryTimeupOnDeskLayer() { + cancelQuizCarryResultEndAfterTimeup(); + const el = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!el) return; + const anchor = el.querySelector('.qc-timeup-desk-anchor') || el.querySelector('.qc-timeup-desk-inner'); + if (anchor) { + anchor.style.left = ''; + anchor.style.top = ''; + anchor.style.width = ''; + anchor.style.height = ''; + } + el.classList.add('is-hidden'); + el.setAttribute('aria-hidden', 'true'); + } + + /** หลังกดรับหลักฐาน (embed preview) — แสดง timeup-txt กลางโต๊ะบนแคนวาส แยกจากป๊อปสรุปคะแนน */ + function showQuizCarryTimeupOnDeskLayer() { + const el = document.getElementById('quiz-carry-timeup-desk-layer'); + if (!el) return; + const img = document.getElementById('quiz-carry-timeup-txt-img'); + if (img) { + img.src = BASE + '/img/quiz-carry/timeup-txt.png?v=' + String(Date.now()); + } + el.classList.remove('is-hidden'); + el.setAttribute('aria-hidden', 'false'); + try { + syncQuizCarryTimeupDeskLayerPosition(); + } catch (e) { /* ignore */ } + if (previewMode && editorEmbedReturn) { + scheduleQuizCarryResultEndAfterTimeup(); + } + } + + function showQuizCarryMissionSummaryOverlay(opts) { + opts = opts || {}; + const forceF = !!opts.forceGradeF; + const overlay = document.getElementById('quiz-carry-mission-overlay'); + if (!overlay) return; + hideQuizCarryTimeupOnDeskLayer(); + const ranks = quizCarryBuildMissionRankList(); + const parts = ranks.map((r) => Math.max(0, Number(r.score) || 0)); + const total = parts.reduce((a, b) => a + b, 0); + const n = ranks.length; + const avgForGrade = n ? Math.min(100, Math.max(0, Math.round(total / n))) : 0; + const grade = forceF ? 'F' : quizCarryGradeFromTeamAverage(avgForGrade); + const reward = grade === 'F' ? null : quizCarryRollCardRewardForGrade(grade); + + const rowEl = document.getElementById('qc-mission-rank-row'); + const totalEl = document.getElementById('qc-mission-total-line'); + const gradeEl = document.getElementById('qc-mission-grade'); + const cardTextEl = document.getElementById('qc-mission-card-text'); + const checkEl = document.getElementById('qc-mission-check'); + const successTxt = document.getElementById('qc-mission-success-txt'); + const rankTagLabels = ['[1st]', '[2nd]', '[3rd]', '[4th]', '[5th]']; + if (rowEl) { + rowEl.innerHTML = ''; + ranks.forEach((r, idx) => { + const rank = idx + 1; + const cell = document.createElement('div'); + cell.className = 'qc-mission-rank-cell'; + const tag = document.createElement('div'); + tag.className = 'qc-mission-rank-tag'; + tag.textContent = rankTagLabels[rank - 1] || ('[' + rank + ']'); + const frame = document.createElement('div'); + frame.className = 'qc-mission-rank-frame'; + const img = document.createElement('img'); + img.className = 'qc-mission-rank-avatar'; + img.alt = ''; + applyQuizCarryRankAvatarTint(img, r.characterId, r.id); + img.onerror = function () { + this.onerror = null; + this.src = 'data:image/svg+xml,' + encodeURIComponent('?'); + }; + frame.appendChild(img); + const nick = document.createElement('div'); + nick.className = 'qc-mission-rank-nick'; + nick.textContent = r.nickname; + nick.title = r.nickname; + const sc = document.createElement('div'); + sc.className = 'qc-mission-rank-score'; + sc.textContent = String(r.score); + cell.appendChild(tag); + cell.appendChild(frame); + cell.appendChild(nick); + cell.appendChild(sc); + rowEl.appendChild(cell); + }); + } + if (totalEl) { + totalEl.textContent = ''; + if (!n) { + totalEl.textContent = '—'; + } else { + const lab = document.createElement('span'); + lab.textContent = 'คะแนนรวม '; + const nums = document.createElement('span'); + nums.className = 'qc-mission-total-nums'; + nums.textContent = '(' + parts.join('+') + ') = ' + total; + const tail = document.createElement('span'); + tail.textContent = ' · เฉลี่ยทีม ' + avgForGrade + '/100'; + totalEl.appendChild(lab); + totalEl.appendChild(nums); + totalEl.appendChild(tail); + } + } + if (gradeEl) { + gradeEl.textContent = grade; + gradeEl.className = 'qc-mission-grade qc-mission-grade--' + String(grade).toLowerCase(); + } + const okMission = grade !== 'F'; + if (checkEl) checkEl.style.display = okMission ? '' : 'none'; + if (successTxt) { + successTxt.style.display = okMission ? '' : 'none'; + if (okMission) successTxt.textContent = 'ภารกิจสำเร็จ'; + } + if (cardTextEl) { + cardTextEl.textContent = ''; + if (grade === 'F') { + const pl = document.createElement('div'); + pl.className = 'qc-mission-reward-plaque qc-mission-reward-plaque--muted'; + const t = document.createElement('div'); + t.className = 'qc-mission-reward-plaque-title'; + t.textContent = 'ไม่ได้รับการ์ด'; + const s = document.createElement('div'); + s.className = 'qc-mission-reward-plaque-sub'; + s.textContent = 'เกรด F'; + pl.appendChild(t); + pl.appendChild(s); + cardTextEl.appendChild(pl); + } else if (reward) { + const pl = document.createElement('div'); + pl.className = 'qc-mission-reward-plaque'; + const t = document.createElement('div'); + t.className = 'qc-mission-reward-plaque-title'; + t.textContent = reward.th; + const s = document.createElement('div'); + s.className = 'qc-mission-reward-plaque-sub'; + s.textContent = reward.en; + pl.appendChild(t); + pl.appendChild(s); + cardTextEl.appendChild(pl); + } + } + overlay.classList.remove('is-hidden'); + const btn = document.getElementById('btn-quiz-carry-mission-done'); + if (btn) { + btn.onclick = function () { + overlay.classList.add('is-hidden'); + if (previewMode && editorEmbedReturn) { + showQuizCarryTimeupOnDeskLayer(); + } else { + window.location.href = 'room-lobby.html?space=' + encodeURIComponent(spaceId) + '&nick=' + encodeURIComponent(nick); + } + }; + } + } + function showQuizCarrySessionCompleteOverlay() { if (quizCarrySessionEnded) return; quizCarrySessionEnded = true; @@ -3308,6 +3729,14 @@ if (qEl) qEl.textContent = playQuizText; const phaseEl = document.getElementById('quiz-game-phase-label'); if (phaseEl) phaseEl.textContent = 'ครบ ' + String(quizCarryRoundsCompleted) + ' ข้อ'; + const missionOv = document.getElementById('quiz-carry-mission-overlay'); + if (missionOv && quizCarryUseMissionSummaryOverlay) { + showQuizCarryMissionSummaryOverlay({ + forceGradeF: !!(playQuizPlayerLocal && playQuizPlayerLocal.eliminated), + }); + renderPlayQuizScoreboard(playLiveQuizScores); + return; + } const ov = document.getElementById('gauntlet-ended-overlay'); const msgEl = document.getElementById('gauntlet-ended-message'); const titleEl = document.getElementById('gauntlet-ended-title'); @@ -3544,6 +3973,8 @@ quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; hideQuizCarryEmbedCountdownOverlay(); + hideQuizCarryTimeupOnDeskLayer(); + hideQuizCarryResultEndLayer(); me.quizCarryHeld = null; others.forEach((o) => { if (!o) return; @@ -3590,6 +4021,8 @@ async function loadQuizCarryPoolAndStart() { quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; + hideQuizCarryTimeupOnDeskLayer(); + hideQuizCarryResultEndLayer(); quizCarrySessionLength = 0; let pool = []; let s = {}; @@ -7748,6 +8181,55 @@ return quizTilesFootprintPlay(px, py).has(tx + ',' + ty); } + /** + * กล่องตรวจโซนห้ามซ้อน (blockPlayer) — วางเหมือนชนกำแพง: กลางแนวนอน + ชิดเท้า + * blockPlayerSeparationW/H: 0 = ใช้เต็มความกว้าง/สูงตัว · ค่าอื่น = จำกัด 1..cw / 1..ch + * รองรับ legacy blockPlayerSeparation เมื่อยังไม่มี W/H ใน JSON + */ + function resolveBlockPlayerNoOverlapBoxWH(md) { + const m = md || mapData; + if (!m) return { cw: 1, ch: 1, boxW: 1, boxH: 1 }; + const { cw, ch } = getCharacterFootprintWH(m); + let w = m.blockPlayerSeparationW != null && m.blockPlayerSeparationW !== '' ? Math.floor(Number(m.blockPlayerSeparationW)) : NaN; + let h = m.blockPlayerSeparationH != null && m.blockPlayerSeparationH !== '' ? Math.floor(Number(m.blockPlayerSeparationH)) : NaN; + const leg = m.blockPlayerSeparation != null && m.blockPlayerSeparation !== '' ? Math.floor(Number(m.blockPlayerSeparation)) : NaN; + const hasW = Number.isFinite(w) && w >= 0; + const hasH = Number.isFinite(h) && h >= 0; + if (!hasW && !hasH && Number.isFinite(leg) && leg >= 1) { + w = Math.min(leg, cw); + h = Math.min(leg, ch); + } else { + if (!hasW) w = 0; + if (!hasH) h = 0; + } + const boxW = !w || w <= 0 ? cw : Math.max(1, Math.min(cw, w)); + const boxH = !h || h <= 0 ? ch : Math.max(1, Math.min(ch, h)); + return { cw, ch, boxW, boxH }; + } + + /** ช่องกริดที่ถือว่า “ผู้เล่นที่ (px,py) กินพื้นที่” สำหรับตรวจโซน blockPlayer — กล่องเดียวกับแนวชนกำแพง */ + function quizTilesBlockPlayerPeerFootprintPlay(px, py) { + const s = new Set(); + if (!mapData) return s; + if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return s; + const { cw, ch, boxW, boxH } = resolveBlockPlayerNoOverlapBoxWH(mapData); + const w = mapData.width || 20, h = mapData.height || 15; + const minTx = Math.floor(px); + const minTy = Math.floor(py); + const offX = minTx + Math.floor((cw - boxW) / 2); + const offY = minTy + (ch - boxH); + for (let ty = offY; ty < offY + boxH; ty++) { + for (let tx = offX; tx < offX + boxW; tx++) { + if (tx >= 0 && ty >= 0 && tx < w && ty < h) s.add(tx + ',' + ty); + } + } + return s; + } + + function playPeerBlockPlayerOccupiesTile(px, py, tx, ty) { + return quizTilesBlockPlayerPeerFootprintPlay(px, py).has(tx + ',' + ty); + } + /** Footprint ชนกำแพง (objects=1) เท่านั้น — hub / interactive / blockPlayer / quiz ใช้ quizTilesFootprintPlay = เต็ม characterCells */ function quizTilesWallCollisionFootprintPlay(px, py) { const s = new Set(); @@ -7858,7 +8340,7 @@ const tx = +p[0], ty = +p[1]; if (!bp[ty] || bp[ty][tx] !== 1) continue; for (const [, o] of others) { - if (playEntityFootprintContainsTile(o.x, o.y, tx, ty)) return false; + if (playPeerBlockPlayerOccupiesTile(o.x, o.y, tx, ty)) return false; } } } @@ -7898,9 +8380,9 @@ if (!bp[ty] || bp[ty][tx] !== 1) continue; for (const [, peer] of others) { if (o && peer === o) continue; - if (playEntityFootprintContainsTile(peer.x, peer.y, tx, ty)) return false; + if (playPeerBlockPlayerOccupiesTile(peer.x, peer.y, tx, ty)) return false; } - if (o && playEntityFootprintContainsTile(me.x, me.y, tx, ty)) return false; + if (o && playPeerBlockPlayerOccupiesTile(me.x, me.y, tx, ty)) return false; } } if (isQuiz()) { @@ -8587,9 +9069,25 @@ if (portrait) { const cid = me.characterId || getPlayCharacterId(); - const fr = getCharacterFrame(cid, me.direction || 'down', Date.now(), !!me.isWalking); - const fb = fr && fr.src ? fr : getCharacterImg(cid, 'down'); - if (fb && fb.src) portrait.src = fb.src; + const dir = me.direction || 'down'; + const nowT = Date.now(); + const walk = !!me.isWalking; + const rawImg = getAvatarImg(cid, dir, nowT, walk); + const tint = me.playTint || playTintFromPeerId(String(myId != null ? myId : 'me')); + const comp = rawImg && cid && tint + ? getPlayTintedAvatarSource(rawImg, cid, dir, nowT, walk, tint) + : rawImg; + if (comp && comp.tagName === 'CANVAS' && comp.width > 0) { + try { + portrait.src = comp.toDataURL('image/png'); + } catch (e) { + if (rawImg && rawImg.src) portrait.src = rawImg.src; + } + } else if (comp && comp.src) { + portrait.src = comp.src; + } else if (rawImg && rawImg.src) { + portrait.src = rawImg.src; + } } if (statusEl) { if (isQuizCarry() && quizCarrySessionEnded) { @@ -9457,6 +9955,8 @@ syncPlayQuizMapPanel(); syncQuizCarryEmbedQuestionStrip(); } + /* timeup ต้องซิงก์ทุกเฟรมแม้จบเซสชัน — อย่าให้อยู่ในเงื่อนไขด้านบนอย่างเดียว */ + syncQuizCarryTimeupDeskLayerPosition(); if (previewFillBots && mapData && !useCyberPlayHud()) { const human = countPlayHumans(); const bots = [...others.keys()].filter(isPreviewBotId).length; diff --git a/www/html/Game/public/room-lobby.html b/www/html/Game/public/room-lobby.html index 4eee670..68cebfa 100644 --- a/www/html/Game/public/room-lobby.html +++ b/www/html/Game/public/room-lobby.html @@ -1043,7 +1043,7 @@ - +
v —
diff --git a/www/html/Game/server.js b/www/html/Game/server.js index ce48bab..2bfca2e 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -126,7 +126,9 @@ function buildCharacterLayerManifest(id, files) { return { ok: true, id, byDir, byDirIdle }; } -const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets'); +/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */ +const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets'); +const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets'); const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); /** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */ const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets'); @@ -730,8 +732,31 @@ function saveGameTimingToFile(d) { } } +function migrateGauntletAssetsFromLegacyIfNeeded() { + try { + if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; + if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); + names.forEach((f) => { + if (!safeGauntletStoredFilename(f)) return; + const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); + const dest = path.join(GAUNTLET_ASSETS_DIR, f); + if (fs.existsSync(dest)) return; + try { + fs.copyFileSync(src, dest); + console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); + } catch (e) { + console.error('[gauntlet-assets] migrate failed', f, e && e.message); + } + }); + } catch (e) { + console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); + } +} + function ensureGauntletAssetsDir() { if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); + migrateGauntletAssetsFromLegacyIfNeeded(); } function loadGauntletAssetsMeta() {