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 — ไม่ซ้ำกับ