minigame 4 80% done

This commit is contained in:
2026-04-30 14:27:22 +00:00
parent d8b1fd8c6b
commit 2ad5401d28
18 changed files with 1446 additions and 38 deletions
+4
View File
@@ -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)"
+1 -1
View File
@@ -72,7 +72,7 @@
<button type="button" class="tab" data-tab="quiz-carry" role="tab" id="tab-quiz-carry" aria-controls="tab-panel-quiz-carry"><span class="tab-label">Minigame-4-คำศัพท์ในกระบวนการยุติธรรม</span><span class="tab-desc" aria-hidden="true"></span></button>
<button type="button" class="tab" data-tab="quiz-battle" role="tab" id="tab-quiz-battle" aria-controls="tab-panel-quiz-battle"><span class="tab-label">Quiz Battle</span><span class="tab-desc">โดม · A B C · หมวด</span></button>
<button type="button" class="tab" data-tab="jump-survive" role="tab" id="tab-jump-survive" aria-controls="tab-panel-jump-survive"><span class="tab-label">กระโดดให้รอด</span><span class="tab-desc">ความสูงกระโดด · timing</span></button>
<button type="button" class="tab" data-tab="game-timing" role="tab" id="tab-game-timing" aria-controls="tab-panel-game-timing"><span class="tab-label">เวลาเกม</span><span class="tab-desc">พรมแดง · tick &amp; laser</span></button>
<button type="button" class="tab" data-tab="game-timing" role="tab" id="tab-game-timing" aria-controls="tab-panel-game-timing"><span class="tab-label">Minigame-2-วิ่งหลบสิ่งกีดขวาง</span><span class="tab-desc" aria-hidden="true"></span></button>
<button type="button" class="tab" data-tab="stack-game" role="tab" id="tab-stack-game" aria-controls="tab-panel-stack-game"><span class="tab-label">Stack</span><span class="tab-desc">สวิง · ความยาวแท่ง</span></button>
<button type="button" class="tab" data-tab="characters" role="tab" id="tab-characters" aria-controls="tab-panel-characters"><span class="tab-label">ตัวละคร</span><span class="tab-desc">อัปโหลด · เลือกใช้ในเกม</span></button>
<button type="button" class="tab" data-tab="accounts" role="tab" id="tab-accounts" aria-controls="tab-panel-accounts"><span class="tab-label">ผู้ใช้</span><span class="tab-desc">บัญชี &amp; COINS</span></button>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+464 -16
View File
@@ -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 80100, B 6079, C 059 */
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('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="%2318223b" width="64" height="64"/><text x="32" y="36" fill="%237aa2f7" font-size="10" text-anchor="middle">?</text></svg>');
};
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;
+28
View File
@@ -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) {
+404 -1
View File
@@ -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 — ไม่ซ้ำกับ <h2> 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 @@
<img id="quiz-map-q-feedback-score" alt="" width="120" height="48" decoding="async" />
</div>
</div>
<div id="quiz-carry-timeup-desk-layer" class="is-hidden" aria-hidden="true">
<div class="qc-timeup-dim" aria-hidden="true"></div>
<div class="qc-timeup-desk-anchor">
<img id="quiz-carry-timeup-txt-img" class="qc-timeup-txt-img" src="/Game/img/quiz-carry/timeup-txt.png" alt="" width="480" height="120" decoding="async" />
</div>
</div>
<div id="quiz-carry-result-end-layer" class="is-hidden" aria-hidden="true" role="img" aria-label="ผลจบภารกิจ">
<div class="qc-result-end-dim" aria-hidden="true"></div>
<img id="quiz-carry-result-end-img" class="qc-result-end-img" src="" alt="" width="900" height="520" decoding="async" />
</div>
</div>
</div>
<div id="play-quiz-scoreboard" class="is-hidden" aria-live="polite">
@@ -1057,9 +1432,37 @@
<button type="button" id="btn-gauntlet-ended-lobby">กลับห้อง (ล็อบบี้) · Back to room lobby</button>
</div>
</div>
<div id="quiz-carry-mission-overlay" class="is-hidden" role="dialog" aria-modal="true" aria-labelledby="qc-mission-title">
<div class="qc-mission-backdrop" aria-hidden="true"></div>
<div class="qc-mission-panel">
<div class="qc-mission-panel-bg" aria-hidden="true"></div>
<div class="qc-mission-panel-inner">
<h2 id="qc-mission-title" class="qc-mission-sr">สรุปผลภารกิจ</h2>
<div id="qc-mission-rank-row" class="qc-mission-rank-row"></div>
<p id="qc-mission-total-line" class="qc-mission-total"></p>
<div class="qc-mission-bottom">
<div class="qc-mission-grade-block">
<div class="qc-mission-section-h">ระดับความสำเร็จ</div>
<div id="qc-mission-grade" class="qc-mission-grade" aria-live="polite">A</div>
</div>
<div class="qc-mission-bonus-block">
<div class="qc-mission-section-h">โบนัสพิเศษ</div>
<div id="qc-mission-bonus-row" class="qc-mission-bonus-row">
<div class="qc-mission-bonus-inline">
<img id="qc-mission-check" class="qc-mission-check" src="/Game/img/quiz-carry/result-check.png" alt="" width="28" height="28" loading="lazy" decoding="async" />
<span id="qc-mission-success-txt" class="qc-mission-success-txt">ภารกิจสำเร็จ</span>
</div>
<div id="qc-mission-card-text" class="qc-mission-card-text"></div>
</div>
</div>
</div>
<button type="button" id="btn-quiz-carry-mission-done"><span class="qc-mission-sr">รับหลักฐาน · Done</span></button>
</div>
</div>
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0166"></script>
<script src="js/play.js?v=0.165"></script>
<script src="js/play.js?v=0.182"></script>
<div class="version-tag">v —</div>
</body>
</html>
+518 -18
View File
@@ -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_<dir>_idle_<n>_layer_<name>.png หรือ id_<dir>_idle_layer_<name>.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 80100, B 6079, C 059 */
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('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="%2318223b" width="64" height="64"/><text x="32" y="36" fill="%237aa2f7" font-size="10" text-anchor="middle">?</text></svg>');
};
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;
+1 -1
View File
@@ -1043,7 +1043,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0122"></script>
<script src="js/room-lobby.js?v=0.0185"></script>
<script src="js/room-lobby.js?v=0.0188"></script>
<div class="version-tag">v —</div>
</body>
</html>
+26 -1
View File
@@ -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() {