diff --git a/scripts/deploy-www.sh b/scripts/deploy-www.sh index 1a3c2bb..0a8859d 100755 --- a/scripts/deploy-www.sh +++ b/scripts/deploy-www.sh @@ -33,6 +33,11 @@ GA="$DST/Game/public/img/gauntlet-assets" mkdir -p "$GA" chown www-data:www-data "$GA" chmod 2775 "$GA" +# Jump Survival (Jumper) — โฟลเดอร์รูป; FTP มัก MKD ไม่ได้ถ้ายังไม่มีบนเซิร์ฟ +JUMPER="$DST/Game/public/img/Jumper" +mkdir -p "$JUMPER" +chown www-data:www-data "$JUMPER" +chmod 2775 "$JUMPER" GAME_SRV_HASH_AFTER="$(hash_game_server_js "$GAME_SERVER_JS")" if [[ -n "$GAME_SRV_HASH_AFTER" && "$GAME_SRV_HASH_BEFORE" != "$GAME_SRV_HASH_AFTER" ]]; then diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 5075d87..3f8d841 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -71,7 +71,7 @@ - + diff --git a/www/html/Game/public/img/Jumper/1.png b/www/html/Game/public/img/Jumper/1.png new file mode 100644 index 0000000..2646cbd Binary files /dev/null and b/www/html/Game/public/img/Jumper/1.png differ diff --git a/www/html/Game/public/img/Jumper/2.png b/www/html/Game/public/img/Jumper/2.png new file mode 100644 index 0000000..58c47cc Binary files /dev/null and b/www/html/Game/public/img/Jumper/2.png differ diff --git a/www/html/Game/public/img/Jumper/3.png b/www/html/Game/public/img/Jumper/3.png new file mode 100644 index 0000000..b479ba8 Binary files /dev/null and b/www/html/Game/public/img/Jumper/3.png differ diff --git a/www/html/Game/public/img/Jumper/Avartar-mark.png b/www/html/Game/public/img/Jumper/Avartar-mark.png new file mode 100644 index 0000000..45a6644 Binary files /dev/null and b/www/html/Game/public/img/Jumper/Avartar-mark.png differ diff --git a/www/html/Game/public/img/Jumper/Avartar.png b/www/html/Game/public/img/Jumper/Avartar.png new file mode 100644 index 0000000..dabf3ad Binary files /dev/null and b/www/html/Game/public/img/Jumper/Avartar.png differ diff --git a/www/html/Game/public/img/Jumper/BG.png b/www/html/Game/public/img/Jumper/BG.png new file mode 100644 index 0000000..6c4686f Binary files /dev/null and b/www/html/Game/public/img/Jumper/BG.png differ diff --git a/www/html/Game/public/img/Jumper/README.txt b/www/html/Game/public/img/Jumper/README.txt new file mode 100644 index 0000000..2aa0355 --- /dev/null +++ b/www/html/Game/public/img/Jumper/README.txt @@ -0,0 +1,9 @@ +Assets for Jump Survival / Jumper mode — sprites, tiles, UI. +Upload files here; path in browser: /Game/img/Jumper/ (public URL) + +ฉาก editor id **mnptfts2** — ไฟล์ UI ที่ play.js อ้างอิง (ถ้าไม่มีจะ fallback ไป gauntlet-assets): +- **popup-Howto.png** — พื้นหลัง HOW TO ก่อนเริ่ม (ข้อความวิธีเล่นใส่ในรูปนี้ — หน้าเกมไม่ซ้อนข้อความ HTML) +- **popup-result.png** — พื้นหลังสรุปภารกิจตอนจบ +- **stamp-sacrifice.png** — ตราประทับ “ผู้เสียสละ” บนผู้เล่นที่ตกรอบ (optional; ถ้าโหลดไม่ได้ใช้ result-Lose_stamp) + +โฟลเดอร์นี้สร้างจาก repo + deploy — ถ้า FTP ยังอัปโหลดไม่ได้ ให้เช็คสิทธิ์ parent diff --git a/www/html/Game/public/img/Jumper/btn-done.png b/www/html/Game/public/img/Jumper/btn-done.png new file mode 100644 index 0000000..6ea7618 Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-done.png differ diff --git a/www/html/Game/public/img/Jumper/btn-joystick-1.png b/www/html/Game/public/img/Jumper/btn-joystick-1.png new file mode 100644 index 0000000..a521d5a Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-joystick-1.png differ diff --git a/www/html/Game/public/img/Jumper/btn-joystick-2.png b/www/html/Game/public/img/Jumper/btn-joystick-2.png new file mode 100644 index 0000000..21b348f Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-joystick-2.png differ diff --git a/www/html/Game/public/img/Jumper/btn-jump.png b/www/html/Game/public/img/Jumper/btn-jump.png new file mode 100644 index 0000000..6fd2381 Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-jump.png differ diff --git a/www/html/Game/public/img/Jumper/btn-mic_off.png b/www/html/Game/public/img/Jumper/btn-mic_off.png new file mode 100644 index 0000000..426093d Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-mic_off.png differ diff --git a/www/html/Game/public/img/Jumper/btn-mic_on.png b/www/html/Game/public/img/Jumper/btn-mic_on.png new file mode 100644 index 0000000..755bb05 Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-mic_on.png differ diff --git a/www/html/Game/public/img/Jumper/btn-ready.png b/www/html/Game/public/img/Jumper/btn-ready.png new file mode 100644 index 0000000..6b50bfc Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-ready.png differ diff --git a/www/html/Game/public/img/Jumper/btn-start.png b/www/html/Game/public/img/Jumper/btn-start.png new file mode 100644 index 0000000..cdc7776 Binary files /dev/null and b/www/html/Game/public/img/Jumper/btn-start.png differ diff --git a/www/html/Game/public/img/Jumper/platfrom-1.png b/www/html/Game/public/img/Jumper/platfrom-1.png new file mode 100644 index 0000000..3fe97d7 Binary files /dev/null and b/www/html/Game/public/img/Jumper/platfrom-1.png differ diff --git a/www/html/Game/public/img/Jumper/platfrom-2.png b/www/html/Game/public/img/Jumper/platfrom-2.png new file mode 100644 index 0000000..df93824 Binary files /dev/null and b/www/html/Game/public/img/Jumper/platfrom-2.png differ diff --git a/www/html/Game/public/img/Jumper/platfrom-3.png b/www/html/Game/public/img/Jumper/platfrom-3.png new file mode 100644 index 0000000..8603a58 Binary files /dev/null and b/www/html/Game/public/img/Jumper/platfrom-3.png differ diff --git a/www/html/Game/public/img/Jumper/popup-Howto.png b/www/html/Game/public/img/Jumper/popup-Howto.png new file mode 100644 index 0000000..d5fb874 Binary files /dev/null and b/www/html/Game/public/img/Jumper/popup-Howto.png differ diff --git a/www/html/Game/public/img/Jumper/popup-result-Line1.png b/www/html/Game/public/img/Jumper/popup-result-Line1.png new file mode 100644 index 0000000..07cbd6c Binary files /dev/null and b/www/html/Game/public/img/Jumper/popup-result-Line1.png differ diff --git a/www/html/Game/public/img/Jumper/popup-result-Line2.png b/www/html/Game/public/img/Jumper/popup-result-Line2.png new file mode 100644 index 0000000..69f13d8 Binary files /dev/null and b/www/html/Game/public/img/Jumper/popup-result-Line2.png differ diff --git a/www/html/Game/public/img/Jumper/popup-result.png b/www/html/Game/public/img/Jumper/popup-result.png new file mode 100644 index 0000000..340a388 Binary files /dev/null and b/www/html/Game/public/img/Jumper/popup-result.png differ diff --git a/www/html/Game/public/img/Jumper/result-Lose_stamp.png b/www/html/Game/public/img/Jumper/result-Lose_stamp.png new file mode 100644 index 0000000..562b3fe Binary files /dev/null and b/www/html/Game/public/img/Jumper/result-Lose_stamp.png differ diff --git a/www/html/Game/public/img/Jumper/result-complete.png b/www/html/Game/public/img/Jumper/result-complete.png new file mode 100644 index 0000000..4b5f062 Binary files /dev/null and b/www/html/Game/public/img/Jumper/result-complete.png differ diff --git a/www/html/Game/public/img/Jumper/result-gameover.png b/www/html/Game/public/img/Jumper/result-gameover.png new file mode 100644 index 0000000..610c6ec Binary files /dev/null and b/www/html/Game/public/img/Jumper/result-gameover.png differ diff --git a/www/html/Game/public/img/Jumper/result-timeup.png b/www/html/Game/public/img/Jumper/result-timeup.png new file mode 100644 index 0000000..c467df7 Binary files /dev/null and b/www/html/Game/public/img/Jumper/result-timeup.png differ diff --git a/www/html/Game/public/img/Jumper/time.png b/www/html/Game/public/img/Jumper/time.png new file mode 100644 index 0000000..cc5acde Binary files /dev/null and b/www/html/Game/public/img/Jumper/time.png differ diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 3ef44c3..cc1e5b2 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -21,6 +21,8 @@ const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId; /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07'; + /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */ + const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; let firstCharacterDefaultResolved = null; @@ -818,6 +820,10 @@ let jumpSurviveJumpQueued = false; let jumpSurviveOnGround = false; let jumpSurviveLastEmitT = 0; + /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ + let jumpSurviveMissionPhase = null; + let jumpSurviveGameEnded = false; + let jumperMissionCountdownTimer = null; /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */ let spaceShooterBullets = []; @@ -2471,6 +2477,358 @@ } function isStack() { return mapData && mapData.gameType === 'stack'; } function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } + + function currentPlayMapId() { + const q = (playSessionMapId || '').trim(); + if (q) return q; + if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim(); + return (playMapIdFromQuery || '').trim(); + } + + function isJumpSurviveMissionUiMapPlay() { + return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; + } + + function isJumpSurviveMissionPregameBlockingPlay() { + if (!isJumpSurviveMissionUiMapPlay()) return false; + return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; + } + + function jumperAssetUrl(file) { + return BASE + '/img/Jumper/' + file; + } + + function hideConflictingOverlaysForJumpSurviveMission() { + hideConflictingOverlaysForGauntletCrown(); + } + + function jumpSurviveTimeLimitSecForMission() { + const m = Number(mapData && mapData.jumpSurviveTimeSec); + if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); + const g = Number(gauntletRuntimeTimeLimitSec); + if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g); + return 60; + } + + function jumpSurviveRemainingSecMission() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; + const lim = jumpSurviveTimeLimitSecForMission(); + if (!(lim > 0)) return null; + const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function applyJumpSurviveJumperPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = jumperAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = jumperAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showJumpSurviveMissionHowtoOverlay() { + if (!isJumpSurviveMissionUiMapPlay()) return; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + jumpSurviveMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.textContent = ''; + st.classList.remove('gch-status--jumper'); + st.classList.add('is-hidden'); + } + const btn = document.getElementById('btn-gch-ready'); + if (btn) { + const humans = quizCarryPregameHumanIds(); + const soleHuman = humans.length === 1; + const canGo = soleHuman || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } + + function beginJumpSurviveMissionCountdownThenRun() { + if (!isJumpSurviveMissionUiMapPlay()) return; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'live'; + jumpSurviveGameEnded = false; + jumpSurviveEliminated = false; + jumpSurviveInitRuntime(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + let n = 3; + numEl.textContent = String(n); + const step = function () { + n--; + if (n > 0) { + numEl.textContent = String(n); + jumperMissionCountdownTimer = setTimeout(step, 1000); + } else { + jumperMissionCountdownTimer = null; + runFinish(); + } + }; + jumperMissionCountdownTimer = setTimeout(step, 1000); + } + + /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ + function jumpSurviveGradeFromSurvivorCount(survivors) { + const s = Math.max(0, Math.floor(Number(survivors) || 0)); + if (s >= 6) return 'A'; + if (s === 5) return 'B'; + if (s === 4) return 'C'; + if (s === 3) return 'D'; + if (s === 2) return 'E'; + return 'F'; + } + + function jumpSurviveRollRewardCardForGrade(grade) { + if (grade === 'F') return null; + const r = Math.random(); + if (grade === 'A' || grade === 'B') { + if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C' || grade === 'D') { + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'E') { + if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + + function jumpSurviveMissionTotalParticipants() { + let n = myId != null ? 1 : 0; + if (others && typeof others.size === 'number') n += others.size; + return n; + } + + function jumpSurviveMissionCountAlive() { + let n = 0; + if (myId != null && !jumpSurviveEliminated) n++; + if (others && typeof others.forEach === 'function') { + others.forEach(function (o) { + if (o && !o.jumpSurviveEliminated) n++; + }); + } + return n; + } + + /** จบทันทีเมื่อไม่มีผู้รอด หรือเหลือคนเดียว (หลายคนในรอบ) — โหมด solo 1 คนยังเล่นจนหมดเวลา */ + function jumpSurviveMissionMaybeEarlyFinish() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const total = jumpSurviveMissionTotalParticipants(); + const alive = jumpSurviveMissionCountAlive(); + if (alive === 0) { + endJumpSurviveMissionRound(); + return; + } + if (total >= 2 && alive === 1) { + endJumpSurviveMissionRound(); + } + } + + function jumpSurviveBuildMissionPayload() { + const rows = []; + const pushEnt = function (id, nickname, characterId, eliminated) { + const alive = !eliminated; + const baseScore = alive ? 100 : 0; + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: baseScore, + eliminated: !!eliminated, + rankBonus: 0, + finalScore: baseScore, + }); + }; + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < rows.length; si++) { + if (rows[si] && !rows[si].eliminated) survivorCount++; + } + const participantCount = rows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = rows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !previewFillBots || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: o.jumpSurviveEliminated ? 0 : 100, + eliminated: !!o.jumpSurviveEliminated, + rankBonus: 0, + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < baseRows.length; si++) { + if (baseRows[si] && !baseRows[si].eliminated) survivorCount++; + } + const participantCount = baseRows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = baseRows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: jumpSurviveRollRewardCardForGrade(grade), + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function endJumpSurviveMissionRound() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; + jumpSurviveGameEnded = true; + jumpSurviveMissionPhase = 'ended'; + applyJumpSurviveJumperPanelImages(); + const mission = jumpSurviveBuildMissionPayload(); + showGauntletCrownMissionOverlay(mission); + } function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } @@ -5375,9 +5733,13 @@ updateGauntletCrownHowtoHud(); } - /** Editor embed: เกรด F = ภาพจบแบบ gameover — เหมือน logic สรุป quiz_carry mission */ + /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */ function gauntletCrownEmbedMissionAnyOk(mission) { if (!mission) return false; + if (mission.uiSkin === 'jumper') { + const sc = Number(mission.survivorCount); + return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F'); + } const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } @@ -5411,6 +5773,7 @@ */ function gauntletCrownBuildDisplayMission(mission) { if (!mission || !Array.isArray(mission.ranked)) return mission; + if (mission.uiSkin === 'jumper') return mission; if (!(previewFillBots && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission; const seen = new Set(); @@ -5490,7 +5853,10 @@ const bonusEl = document.getElementById('gcm-bonus'); const btn = document.getElementById('btn-gcm-done'); if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return; - const disp = gauntletCrownBuildDisplayMission(mission); + let disp = gauntletCrownBuildDisplayMission(mission); + if (mission.uiSkin === 'jumper') { + disp = jumpSurviveMergePreviewBotsMission(disp) || disp; + } if (!disp || !Array.isArray(disp.ranked)) return; rowEl.innerHTML = ''; disp.ranked.forEach((r) => { @@ -5529,8 +5895,14 @@ if (r.eliminated) { const st = document.createElement('img'); st.className = 'gcm-stamp'; - st.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; - st.alt = 'เสียชีวิต'; + const jSkin = mission && mission.uiSkin === 'jumper'; + st.src = jSkin ? jumperAssetUrl('stamp-sacrifice.png') : (BASE + '/img/gauntlet-assets/result-Lose_stamp.png'); + st.alt = jSkin ? 'ผู้เสียสละ' : 'เสียชีวิต'; + st.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; + this.alt = 'เสียชีวิต'; + }; frame.appendChild(st); } const nick = document.createElement('div'); @@ -5538,8 +5910,9 @@ nick.textContent = r.nickname || '—'; const sc = document.createElement('div'); sc.className = 'gcm-sc'; + const jumpPlain = mission && mission.uiSkin === 'jumper'; const botScorePlain = previewFillBots && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id); - sc.textContent = botScorePlain + sc.textContent = (jumpPlain || botScorePlain) ? String(Math.max(0, Number(r.baseScore) || 0)) : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')'; cell.appendChild(tag); @@ -5552,7 +5925,14 @@ const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0))); const sum = Math.max(0, Number(disp.totalSum) || 0); const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0)); - totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; + const jumperSkin = mission && mission.uiSkin === 'jumper'; + if (jumperSkin) { + const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0)); + const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); + totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด'; + } else { + totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; + } } if (gradeEl) { const g = String(disp.grade || 'C').toUpperCase().charAt(0); @@ -5561,28 +5941,39 @@ } if (bonusEl) { bonusEl.innerHTML = ''; - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.alignItems = 'center'; - row.style.gap = '8px'; - const chk = document.createElement('img'); - chk.src = BASE + '/img/gauntlet-assets/result-check.png'; - chk.alt = ''; - chk.width = 26; - chk.height = 26; - const sp = document.createElement('span'); - sp.style.color = '#9ece6a'; - sp.style.fontWeight = '700'; - sp.textContent = 'ภารกิจสำเร็จ'; - row.appendChild(chk); - row.appendChild(sp); - bonusEl.appendChild(row); + const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0); + const missionOk = gLetter !== 'F'; + if (missionOk) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + const chk = document.createElement('img'); + chk.src = BASE + '/img/gauntlet-assets/result-check.png'; + chk.alt = ''; + chk.width = 26; + chk.height = 26; + const sp = document.createElement('span'); + sp.style.color = '#9ece6a'; + sp.style.fontWeight = '700'; + sp.textContent = 'ภารกิจสำเร็จ'; + row.appendChild(chk); + row.appendChild(sp); + bonusEl.appendChild(row); + } const rc = disp.rewardCard; - if (rc && rc.th) { + if (missionOk && rc && rc.th) { const pl = document.createElement('div'); pl.className = 'gcm-plaque'; pl.innerHTML = '' + String(rc.th) + '' + String(rc.en || '') + ''; bonusEl.appendChild(pl); + } else if (!missionOk) { + const pl = document.createElement('div'); + pl.className = 'gcm-plaque'; + pl.style.borderColor = 'rgba(148, 153, 168, 0.45)'; + pl.style.opacity = '0.95'; + pl.innerHTML = 'ไม่ได้รับการ์ดเกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์'; + bonusEl.appendChild(pl); } } ov.classList.remove('is-hidden'); @@ -7342,8 +7733,14 @@ jumpSurvivePlatformScrollPx = 0; const { cw, ch } = getCharacterFootprintWH(mapData); const ts = tileSize; - jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts; - jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts; + if (isJumpSurviveMissionUiMapPlay()) { + const w = mapData.width || 20, h = mapData.height || 15; + jumpSurviveCamCenterX = (w * ts) * 0.5; + jumpSurviveCamCenterY = (h * ts) * 0.5; + } else { + jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts; + jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts; + } } function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) { @@ -7420,8 +7817,12 @@ } function jumpSurviveCameraTargetPx(md) { - const { cw, ch } = getCharacterFootprintWH(md); const ts = tileSize; + const w = md.width || 20, h = md.height || 15; + if (isJumpSurviveMissionUiMapPlay()) { + return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 }; + } + const { cw, ch } = getCharacterFootprintWH(md); const cx = (e) => (e.x + cw * 0.5) * ts; const cy = (e) => (e.y + ch * 0.5) * ts; if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) }; @@ -7527,6 +7928,14 @@ function jumpSurviveTickFrame() { if (!mapData || !isJumpSurvive()) return; + if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const remEnd = jumpSurviveRemainingSecMission(); + if (remEnd !== null && remEnd <= 0) { + endJumpSurviveMissionRound(); + return; + } + } const md = mapData; const ts = tileSize; const now = performance.now(); @@ -7544,6 +7953,7 @@ if (jumpSurviveEliminated) { stepJumpSurvivePreviewBots(dt, halfViewH); + jumpSurviveMissionMaybeEarlyFinish(); return; } @@ -7592,6 +8002,8 @@ socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } } + + jumpSurviveMissionMaybeEarlyFinish(); } function gridImageLibIdleUrlPlay(entry) { @@ -7907,6 +8319,7 @@ gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), gauntletEliminated: !!p.gauntletEliminated, + jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { @@ -7969,7 +8382,24 @@ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; - jumpSurviveInitRuntime(); + jumpSurviveGameEnded = false; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + if (isJumpSurviveMissionUiMapPlay()) { + jumpSurviveMissionPhase = 'howto'; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (t) { if (t) applyGauntletTimingFromServer(t); }) + .catch(function () {}); + setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50); + } else { + jumpSurviveMissionPhase = null; + jumpSurviveInitRuntime(); + } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; @@ -8011,6 +8441,15 @@ const gccR = document.getElementById('gauntlet-crown-countdown'); if (gccR) gccR.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50); + } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) { + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + const gccRj = document.getElementById('gauntlet-crown-countdown'); + if (gccRj) gccRj.classList.add('is-hidden'); + gauntletCrownPregamePhase = null; + gauntletCrownLobbyReadyMap = {}; } else { const hov = document.getElementById('gauntlet-crown-howto-overlay'); if (hov) { @@ -8105,6 +8544,7 @@ gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { @@ -8527,9 +8967,12 @@ } } else { gauntletEndsAtMs = null; - const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); - if (hto3) hto3.classList.add('is-hidden'); - gauntletCrownHowtoVisible = false; + const skipHideCrownShell = mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay(); + if (!skipHideCrownShell) { + const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); + if (hto3) hto3.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + } gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { @@ -8571,7 +9014,24 @@ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; - jumpSurviveInitRuntime(); + jumpSurviveGameEnded = false; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + if (isJumpSurviveMissionUiMapPlay()) { + jumpSurviveMissionPhase = 'howto'; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60); + } else { + jumpSurviveMissionPhase = null; + jumpSurviveInitRuntime(); + } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; @@ -9630,7 +10090,8 @@ ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)' : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN' : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE' - : (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' : 'JUMP SURVIVE · NODE UPLINK'))); + : (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' + : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))); } if (timeVal) { if (isQuizCarry()) { @@ -9677,6 +10138,13 @@ const rem = balloonBossRemainingSecPlay(); timeVal.textContent = rem != null ? String(rem) : '∞'; } + } else if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) { + timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0'); + } else { + const rem = jumpSurviveRemainingSecMission(); + timeVal.textContent = rem != null ? String(rem) : '∞'; + } } else { const t = jumpSurviveSessionStartMs > 0 ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000)) @@ -9724,9 +10192,15 @@ ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE' : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)'); } else if (isJumpSurvive()) { - hintEl.textContent = jumpSurviveEliminated - ? 'Spectate remaining nodes · Exit room to reset session' - : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)'; + if (isJumpSurviveMissionUiMapPlay()) { + hintEl.textContent = jumpSurviveEliminated + ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)' + : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน'; + } else { + hintEl.textContent = jumpSurviveEliminated + ? 'Spectate remaining nodes · Exit room to reset session' + : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)'; + } } else if (isSpaceShooter()) { hintEl.textContent = spaceShooterGameEnded ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay' @@ -9758,6 +10232,7 @@ const ul = document.getElementById('play-cyber-score-list'); if (!ul) return; const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); + const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay(); const rows = []; rows.push({ id: myId, @@ -9766,7 +10241,8 @@ score: isQuizCarry() ? cyberHudQuizCarryPeerScore(myId) : (isGauntlet() ? (me.gauntletScore || 0) : (isSpaceShooter() ? (me.spaceShooterScore || 0) - : (isBalloonBoss() ? (me.balloonBossScore || 0) : 0))), + : (isBalloonBoss() ? (me.balloonBossScore || 0) + : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(me.y), eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated) || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated), @@ -9780,7 +10256,8 @@ score: isQuizCarry() ? cyberHudQuizCarryPeerScore(id) : (isGauntlet() ? (o.gauntletScore || 0) : (isSpaceShooter() ? (o.spaceShooterScore || 0) - : (isBalloonBoss() ? (o.balloonBossScore || 0) : 0))), + : (isBalloonBoss() ? (o.balloonBossScore || 0) + : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(o.y), eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated) || !!(isBalloonBoss() && o.balloonBossEliminated) @@ -9789,7 +10266,7 @@ }); }); let leaderId = null; - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) { + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { let mx = -1; for (let i = 0; i < rows.length; i++) { if (rows[i].score > mx) { @@ -9807,7 +10284,11 @@ } } rows.sort((a, b) => { - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) return b.score - a.score; + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { + if (b.score !== a.score) return b.score - a.score; + if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; + return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th'); + } if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; return a.y - b.y; }); @@ -9856,7 +10337,7 @@ mid.appendChild(st); const sc = document.createElement('span'); sc.className = 'play-cyber-score-val'; - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) { + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { sc.textContent = String(row.score); } else { sc.textContent = row.eliminated ? '—' : String(r + 1); @@ -10652,6 +11133,10 @@ } } if (isJumpSurvive() && mapData && !isChatFocused()) { + if (isJumpSurviveMissionPregameBlockingPlay()) { + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); + return; + } if (jumpSurviveEliminated) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); return; @@ -10720,6 +11205,12 @@ return; } if (isJumpSurvive()) { + if (isJumpSurviveMissionPregameBlockingPlay()) { + me.isWalking = false; + draw(); + requestAnimationFrame(tick); + return; + } jumpSurviveTickFrame(); draw(); requestAnimationFrame(tick); @@ -10935,6 +11426,13 @@ const btn = document.getElementById('btn-gch-ready'); if (!btn) return; btn.addEventListener('click', () => { + if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'howto') return; + const humans = quizCarryPregameHumanIds(); + if (!(humans.length === 1 || isMePlayHost())) return; + beginJumpSurviveMissionCountdownThenRun(); + return; + } if (!isGauntletCrownHeistMapPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null || !isMePlayHost()) return; diff --git a/www/html/Game/public/js/version.js b/www/html/Game/public/js/version.js index 2917ad9..dbe50aa 100644 --- a/www/html/Game/public/js/version.js +++ b/www/html/Game/public/js/version.js @@ -1,6 +1,6 @@ // ทุกครั้งที่มีการเพิ่มหรือเปลี่ยน ให้เพิ่ม v +0.0001 // หลังแก้ค่าแล้วต้อง copy ไป path ที่ Nginx ชี้ (หรือรัน copy-frogger-files-only.sh) ถึงจะเห็นบนเว็บ -window.APP_VERSION = '0.0175'; +window.APP_VERSION = '0.0180'; document.addEventListener('DOMContentLoaded', function () { var t = document.querySelector('.version-tag'); if (t) t.textContent = 'v ' + window.APP_VERSION; diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index 2fb1d91..d583ab9 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -572,15 +572,21 @@ overflow: auto; } .play-quiz-scoreboard-list li { - display: flex; - justify-content: space-between; - gap: 0.4rem; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(2.25rem, auto); + align-items: baseline; + column-gap: 0.5rem; font-size: 0.74rem; color: #c0caf5; padding: 0.15rem 0; } .play-quiz-scoreboard-me { color: #9ece6a !important; font-weight: 700; } - .play-quiz-scoreboard-val { font-variant-numeric: tabular-nums; font-weight: 800; } + .play-quiz-scoreboard-val { + font-variant-numeric: tabular-nums; + font-weight: 800; + text-align: right; + justify-self: end; + } .play-quiz-scoreboard-me .play-quiz-scoreboard-val { color: #9ece6a; } #play-quiz-feedback { position: fixed; @@ -686,7 +692,7 @@ width: clamp(68px, 16vw, 104px); display: flex; flex-direction: column; - align-items: center; + align-items: stretch; gap: 4px; text-align: center; color: #e2e8f0; @@ -731,20 +737,26 @@ pointer-events: none; } .qc-mission-rank-nick { + flex: 1 1 auto; + min-height: 2.5em; + display: flex; + align-items: flex-end; + justify-content: center; 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; + text-align: center; } .qc-mission-rank-score { + flex: 0 0 auto; + margin-top: auto; + font-variant-numeric: tabular-nums; font-size: clamp(0.62rem, 1.85vw, 0.78rem); font-weight: 800; color: #f6e08a; @@ -799,6 +811,8 @@ .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--d { color: #bb9af7; text-shadow: 0 0 20px rgba(187, 154, 247, 0.4); } + .qc-mission-grade--e { color: #7dcfff; text-shadow: 0 0 20px rgba(125, 207, 255, 0.35); } .qc-mission-grade--f { color: #f7768e; text-shadow: 0 0 22px rgba(247, 118, 140, 0.45); } .qc-mission-bonus-row { display: flex; @@ -1134,6 +1148,7 @@ display: flex; flex-wrap: wrap; justify-content: center; + align-items: stretch; gap: clamp(6px, 1.5vw, 10px); margin-bottom: 8px; } @@ -1141,8 +1156,16 @@ flex: 0 0 auto; width: clamp(72px, 18vw, 100px); text-align: center; + display: flex; + flex-direction: column; + align-items: stretch; } .gcm-rank-tag { + flex: 0 0 auto; + min-height: 1.15em; + display: flex; + align-items: center; + justify-content: center; font-size: clamp(0.55rem, 1.4vw, 0.65rem); font-weight: 800; color: #f6e08a; @@ -1150,6 +1173,7 @@ } .gcm-frame { position: relative; + flex: 0 0 auto; width: 100%; aspect-ratio: 1; border-radius: 8px; @@ -1173,17 +1197,28 @@ pointer-events: none; } .gcm-nick { + flex: 1 1 auto; + min-height: 2.55em; + display: flex; + align-items: flex-end; + justify-content: center; font-size: clamp(0.52rem, 1.35vw, 0.65rem); font-weight: 700; color: #e2e8f0; margin-top: 4px; word-break: break-word; + line-height: 1.15; } .gcm-sc { + flex: 0 0 auto; + width: 100%; + text-align: center; + font-variant-numeric: tabular-nums; font-size: clamp(0.58rem, 1.5vw, 0.72rem); font-weight: 800; color: #f6e08a; - margin-top: 2px; + margin-top: auto; + padding-top: 3px; } .gcm-total { text-align: center; @@ -1219,6 +1254,9 @@ .gcm-grade--a { color: #9ece6a; text-shadow: 0 0 20px rgba(158, 206, 106, 0.5); } .gcm-grade--b { color: #7aa2f7; } .gcm-grade--c { color: #e0af68; } + .gcm-grade--d { color: #bb9af7; text-shadow: 0 0 16px rgba(187, 154, 247, 0.4); } + .gcm-grade--e { color: #7dcfff; text-shadow: 0 0 16px rgba(125, 207, 255, 0.35); } + .gcm-grade--f { color: #f7768e; text-shadow: 0 0 18px rgba(247, 118, 140, 0.45); } .gcm-bonus { display: flex; flex-direction: column; @@ -1373,7 +1411,7 @@ } .play-cyber-score-row { display: grid; - grid-template-columns: 36px 1fr auto; + grid-template-columns: 36px minmax(0, 1fr) minmax(2.75rem, auto); align-items: center; gap: 0.35rem; padding: 0.28rem 0; @@ -1418,6 +1456,9 @@ color: #7af8ff; text-shadow: 0 0 10px rgba(0, 255, 240, 0.35); font-variant-numeric: tabular-nums; + text-align: right; + justify-self: end; + min-width: 2.5rem; } .play-cyber-lead-crown { display: inline-block; @@ -1858,8 +1899,8 @@ - - + +
v —
diff --git a/www/html/Game/public/play.js b/www/html/Game/public/play.js index 3ef44c3..cc1e5b2 100644 --- a/www/html/Game/public/play.js +++ b/www/html/Game/public/play.js @@ -21,6 +21,8 @@ const quizCarryUseMissionSummaryOverlay = playMapIdFromQuery === quizCarryMissionSummaryMapId; /** Gauntlet พรมแดง — ฉากนี้จาก editor (?map=mno9kb07): วาดตัวหันขวา + ใช้รูป lane/laser จาก game-timing */ const GAUNTLET_FACE_RIGHT_MAP_ID = 'mno9kb07'; + /** Jump Survival ฉาก Jumper — UI สรุปภารกิจแบบ crown + รูปใน /img/Jumper/ (editor ?map=mnptfts2) */ + const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; /** จนกว่าโหลด /api/characters — placeholder ชั่วคราวก่อน join */ const LEGACY_PLACEHOLDER_CHARACTER_ID = 'Chatest'; let firstCharacterDefaultResolved = null; @@ -818,6 +820,10 @@ let jumpSurviveJumpQueued = false; let jumpSurviveOnGround = false; let jumpSurviveLastEmitT = 0; + /** mnptfts2: howto → countdown → live → ended (คะแนน 100 ผู้รอดสุดท้าย) */ + let jumpSurviveMissionPhase = null; + let jumpSurviveGameEnded = false; + let jumperMissionCountdownTimer = null; /** space_shooter — ยิงหิน (จุดเกิดจากกริด P1–P6) */ let spaceShooterBullets = []; @@ -2471,6 +2477,358 @@ } function isStack() { return mapData && mapData.gameType === 'stack'; } function isJumpSurvive() { return mapData && mapData.gameType === 'jump_survive'; } + + function currentPlayMapId() { + const q = (playSessionMapId || '').trim(); + if (q) return q; + if (mapData && mapData.id != null && String(mapData.id).trim() !== '') return String(mapData.id).trim(); + return (playMapIdFromQuery || '').trim(); + } + + function isJumpSurviveMissionUiMapPlay() { + return isJumpSurvive() && currentPlayMapId() === JUMP_SURVIVE_MISSION_MAP_ID; + } + + function isJumpSurviveMissionPregameBlockingPlay() { + if (!isJumpSurviveMissionUiMapPlay()) return false; + return jumpSurviveMissionPhase != null && jumpSurviveMissionPhase !== 'live' && jumpSurviveMissionPhase !== 'ended'; + } + + function jumperAssetUrl(file) { + return BASE + '/img/Jumper/' + file; + } + + function hideConflictingOverlaysForJumpSurviveMission() { + hideConflictingOverlaysForGauntletCrown(); + } + + function jumpSurviveTimeLimitSecForMission() { + const m = Number(mapData && mapData.jumpSurviveTimeSec); + if (Number.isFinite(m) && m > 0 && m < 7200) return Math.floor(m); + const g = Number(gauntletRuntimeTimeLimitSec); + if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g); + return 60; + } + + function jumpSurviveRemainingSecMission() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveSessionStartMs <= 0) return null; + const lim = jumpSurviveTimeLimitSecForMission(); + if (!(lim > 0)) return null; + const elapsed = (performance.now() - jumpSurviveSessionStartMs) / 1000; + return Math.max(0, Math.ceil(lim - elapsed)); + } + + function applyJumpSurviveJumperPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = jumperAssetUrl('popup-Howto.png'); + howtoBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-Howto.png'; + }; + } + const resBg = document.querySelector('#gauntlet-crown-mission-overlay .gcm-bg'); + if (resBg) { + resBg.src = jumperAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + + function showJumpSurviveMissionHowtoOverlay() { + if (!isJumpSurviveMissionUiMapPlay()) return; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + const cd = document.getElementById('gauntlet-crown-countdown'); + if (cd) cd.classList.add('is-hidden'); + jumpSurviveMissionPhase = 'howto'; + const ov = document.getElementById('gauntlet-crown-howto-overlay'); + if (!ov) return; + gauntletCrownHowtoVisible = true; + ov.classList.remove('is-hidden'); + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.textContent = ''; + st.classList.remove('gch-status--jumper'); + st.classList.add('is-hidden'); + } + const btn = document.getElementById('btn-gch-ready'); + if (btn) { + const humans = quizCarryPregameHumanIds(); + const soleHuman = humans.length === 1; + const canGo = soleHuman || isMePlayHost(); + btn.classList.remove('is-start-phase'); + btn.classList.toggle('is-read-only', !canGo); + btn.disabled = !canGo; + btn.title = canGo ? 'READY — เริ่มนับถอยหลัง' : 'รอโฮสต์ · Wait for host'; + btn.setAttribute('aria-pressed', 'false'); + } + } + + function beginJumpSurviveMissionCountdownThenRun() { + if (!isJumpSurviveMissionUiMapPlay()) return; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'countdown'; + const howto = document.getElementById('gauntlet-crown-howto-overlay'); + if (howto) howto.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + const st = document.getElementById('gauntlet-crown-howto-status'); + if (st) { + st.classList.add('is-hidden'); + st.classList.remove('gch-status--jumper'); + } + const cd = document.getElementById('gauntlet-crown-countdown'); + const numEl = document.getElementById('gauntlet-crown-countdown-num'); + const runFinish = function () { + if (cd) cd.classList.add('is-hidden'); + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + jumpSurviveMissionPhase = 'live'; + jumpSurviveGameEnded = false; + jumpSurviveEliminated = false; + jumpSurviveInitRuntime(); + }; + if (!cd || !numEl) { + runFinish(); + return; + } + cd.classList.remove('is-hidden'); + let n = 3; + numEl.textContent = String(n); + const step = function () { + n--; + if (n > 0) { + numEl.textContent = String(n); + jumperMissionCountdownTimer = setTimeout(step, 1000); + } else { + jumperMissionCountdownTimer = null; + runFinish(); + } + }; + jumperMissionCountdownTimer = setTimeout(step, 1000); + } + + /** Jumper ภารกิจ mnptfts2: เกรดจากจำนวนผู้รอด — 6=A … 3=D; น้อยกว่า 2 = F; พอดี 2 คน = E */ + function jumpSurviveGradeFromSurvivorCount(survivors) { + const s = Math.max(0, Math.floor(Number(survivors) || 0)); + if (s >= 6) return 'A'; + if (s === 5) return 'B'; + if (s === 4) return 'C'; + if (s === 3) return 'D'; + if (s === 2) return 'E'; + return 'F'; + } + + function jumpSurviveRollRewardCardForGrade(grade) { + if (grade === 'F') return null; + const r = Math.random(); + if (grade === 'A' || grade === 'B') { + if (r < 0.35) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.7) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'C' || grade === 'D') { + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (grade === 'E') { + if (r < 0.12) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + if (r < 0.25) return { kind: 'bail', th: 'เหรียญประกัน · Bail Coin', en: 'Bail Coin' }; + if (r < 0.45) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; + return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; + } + + function jumpSurviveMissionTotalParticipants() { + let n = myId != null ? 1 : 0; + if (others && typeof others.size === 'number') n += others.size; + return n; + } + + function jumpSurviveMissionCountAlive() { + let n = 0; + if (myId != null && !jumpSurviveEliminated) n++; + if (others && typeof others.forEach === 'function') { + others.forEach(function (o) { + if (o && !o.jumpSurviveEliminated) n++; + }); + } + return n; + } + + /** จบทันทีเมื่อไม่มีผู้รอด หรือเหลือคนเดียว (หลายคนในรอบ) — โหมด solo 1 คนยังเล่นจนหมดเวลา */ + function jumpSurviveMissionMaybeEarlyFinish() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const total = jumpSurviveMissionTotalParticipants(); + const alive = jumpSurviveMissionCountAlive(); + if (alive === 0) { + endJumpSurviveMissionRound(); + return; + } + if (total >= 2 && alive === 1) { + endJumpSurviveMissionRound(); + } + } + + function jumpSurviveBuildMissionPayload() { + const rows = []; + const pushEnt = function (id, nickname, characterId, eliminated) { + const alive = !eliminated; + const baseScore = alive ? 100 : 0; + rows.push({ + id: id, + nickname: (nickname && String(nickname).trim()) ? String(nickname).trim() : String(id), + characterId: characterId ?? null, + baseScore: baseScore, + eliminated: !!eliminated, + rankBonus: 0, + finalScore: baseScore, + }); + }; + if (myId != null) { + pushEnt(myId, (me.nickname || nick || 'คุณ').trim() || 'คุณ', me.characterId || getPlayCharacterId(), jumpSurviveEliminated); + } + others.forEach(function (o, id) { + if (!o) return; + pushEnt(id, o.nickname, o.characterId, !!o.jumpSurviveEliminated); + }); + rows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < rows.length; si++) { + if (rows[si] && !rows[si].eliminated) survivorCount++; + } + const participantCount = rows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = rows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: row.baseScore, + eliminated: row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: row.baseScore, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + const rewardCard = jumpSurviveRollRewardCardForGrade(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function jumpSurviveMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'jumper' || !previewFillBots || !others || typeof others.forEach !== 'function') return mission; + const seen = new Set(); + (mission.ranked || []).forEach(function (r) { + if (r && r.id != null) seen.add(String(r.id)); + }); + const baseRows = (mission.ranked || []).map(function (r) { + return { + id: r.id, + nickname: r.nickname, + characterId: r.characterId, + baseScore: Math.max(0, Number(r.baseScore) || 0), + eliminated: !!r.eliminated, + rankBonus: 0, + }; + }); + others.forEach(function (o, id) { + if (!o || !isPreviewBotId(id)) return; + const sid = String(id); + if (seen.has(sid)) return; + seen.add(sid); + baseRows.push({ + id: id, + nickname: (o.nickname && String(o.nickname).trim()) ? String(o.nickname).trim() : sid, + characterId: o.characterId ?? null, + baseScore: o.jumpSurviveEliminated ? 0 : 100, + eliminated: !!o.jumpSurviveEliminated, + rankBonus: 0, + }); + }); + baseRows.sort(function (a, b) { + if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; + return String(a.nickname).localeCompare(String(b.nickname), 'th') || String(a.id).localeCompare(String(b.id)); + }); + let survivorCount = 0; + for (let si = 0; si < baseRows.length; si++) { + if (baseRows[si] && !baseRows[si].eliminated) survivorCount++; + } + const participantCount = baseRows.length; + const grade = jumpSurviveGradeFromSurvivorCount(survivorCount); + const top = baseRows.slice(0, 5); + const ranked = top.map(function (row, idx) { + const pos = idx + 1; + const bs = Math.max(0, Number(row.baseScore) || 0); + return { + id: row.id, + nickname: row.nickname, + characterId: row.characterId, + baseScore: bs, + eliminated: !!row.eliminated, + rank: pos, + rankLabel: pos === 1 ? '1st' : pos === 2 ? '2nd' : pos === 3 ? '3rd' : String(pos), + rankBonus: 0, + finalScore: bs, + }; + }); + const totalParts = ranked.map(function (r) { return r.baseScore; }); + const totalSum = totalParts.reduce(function (s, n) { return s + n; }, 0); + const n = ranked.length || 1; + const averageScore = Math.floor(totalSum / n); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: jumpSurviveRollRewardCardForGrade(grade), + totalParts: totalParts, + uiSkin: 'jumper', + survivorCount: survivorCount, + participantCount: participantCount, + }; + } + + function endJumpSurviveMissionRound() { + if (!isJumpSurviveMissionUiMapPlay() || jumpSurviveGameEnded) return; + jumpSurviveGameEnded = true; + jumpSurviveMissionPhase = 'ended'; + applyJumpSurviveJumperPanelImages(); + const mission = jumpSurviveBuildMissionPayload(); + showGauntletCrownMissionOverlay(mission); + } function isSpaceShooter() { return mapData && mapData.gameType === 'space_shooter'; } function isBalloonBoss() { return mapData && mapData.gameType === 'balloon_boss'; } function isQuizCarry() { return mapData && mapData.gameType === 'quiz_carry'; } @@ -5375,9 +5733,13 @@ updateGauntletCrownHowtoHud(); } - /** Editor embed: เกรด F = ภาพจบแบบ gameover — เหมือน logic สรุป quiz_carry mission */ + /** Editor embed: quiz_carry / crown ใช้เกรด F = gameover — Jumper: ภาพจบใช้เฉพาะเมื่อไม่มีผู้รอด (0 คน) */ function gauntletCrownEmbedMissionAnyOk(mission) { if (!mission) return false; + if (mission.uiSkin === 'jumper') { + const sc = Number(mission.survivorCount); + return Number.isFinite(sc) ? sc > 0 : (String(mission.grade || '').trim().toUpperCase().charAt(0) !== 'F'); + } const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } @@ -5411,6 +5773,7 @@ */ function gauntletCrownBuildDisplayMission(mission) { if (!mission || !Array.isArray(mission.ranked)) return mission; + if (mission.uiSkin === 'jumper') return mission; if (!(previewFillBots && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission; const seen = new Set(); @@ -5490,7 +5853,10 @@ const bonusEl = document.getElementById('gcm-bonus'); const btn = document.getElementById('btn-gcm-done'); if (!ov || !rowEl || !mission || !Array.isArray(mission.ranked)) return; - const disp = gauntletCrownBuildDisplayMission(mission); + let disp = gauntletCrownBuildDisplayMission(mission); + if (mission.uiSkin === 'jumper') { + disp = jumpSurviveMergePreviewBotsMission(disp) || disp; + } if (!disp || !Array.isArray(disp.ranked)) return; rowEl.innerHTML = ''; disp.ranked.forEach((r) => { @@ -5529,8 +5895,14 @@ if (r.eliminated) { const st = document.createElement('img'); st.className = 'gcm-stamp'; - st.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; - st.alt = 'เสียชีวิต'; + const jSkin = mission && mission.uiSkin === 'jumper'; + st.src = jSkin ? jumperAssetUrl('stamp-sacrifice.png') : (BASE + '/img/gauntlet-assets/result-Lose_stamp.png'); + st.alt = jSkin ? 'ผู้เสียสละ' : 'เสียชีวิต'; + st.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/result-Lose_stamp.png'; + this.alt = 'เสียชีวิต'; + }; frame.appendChild(st); } const nick = document.createElement('div'); @@ -5538,8 +5910,9 @@ nick.textContent = r.nickname || '—'; const sc = document.createElement('div'); sc.className = 'gcm-sc'; + const jumpPlain = mission && mission.uiSkin === 'jumper'; const botScorePlain = previewFillBots && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id); - sc.textContent = botScorePlain + sc.textContent = (jumpPlain || botScorePlain) ? String(Math.max(0, Number(r.baseScore) || 0)) : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')'; cell.appendChild(tag); @@ -5552,7 +5925,14 @@ const parts = (disp.totalParts || []).map((n) => String(Math.max(0, Number(n) || 0))); const sum = Math.max(0, Number(disp.totalSum) || 0); const avg = Math.max(0, Math.floor(Number(disp.averageScore) || 0)); - totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; + const jumperSkin = mission && mission.uiSkin === 'jumper'; + if (jumperSkin) { + const sc = Math.max(0, Math.floor(Number(disp.survivorCount) || 0)); + const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); + totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้รอด ' + sc + '/' + pc + ' → เกรด'; + } else { + totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; + } } if (gradeEl) { const g = String(disp.grade || 'C').toUpperCase().charAt(0); @@ -5561,28 +5941,39 @@ } if (bonusEl) { bonusEl.innerHTML = ''; - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.alignItems = 'center'; - row.style.gap = '8px'; - const chk = document.createElement('img'); - chk.src = BASE + '/img/gauntlet-assets/result-check.png'; - chk.alt = ''; - chk.width = 26; - chk.height = 26; - const sp = document.createElement('span'); - sp.style.color = '#9ece6a'; - sp.style.fontWeight = '700'; - sp.textContent = 'ภารกิจสำเร็จ'; - row.appendChild(chk); - row.appendChild(sp); - bonusEl.appendChild(row); + const gLetter = String(disp.grade || 'C').toUpperCase().charAt(0); + const missionOk = gLetter !== 'F'; + if (missionOk) { + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + const chk = document.createElement('img'); + chk.src = BASE + '/img/gauntlet-assets/result-check.png'; + chk.alt = ''; + chk.width = 26; + chk.height = 26; + const sp = document.createElement('span'); + sp.style.color = '#9ece6a'; + sp.style.fontWeight = '700'; + sp.textContent = 'ภารกิจสำเร็จ'; + row.appendChild(chk); + row.appendChild(sp); + bonusEl.appendChild(row); + } const rc = disp.rewardCard; - if (rc && rc.th) { + if (missionOk && rc && rc.th) { const pl = document.createElement('div'); pl.className = 'gcm-plaque'; pl.innerHTML = '' + String(rc.th) + '' + String(rc.en || '') + ''; bonusEl.appendChild(pl); + } else if (!missionOk) { + const pl = document.createElement('div'); + pl.className = 'gcm-plaque'; + pl.style.borderColor = 'rgba(148, 153, 168, 0.45)'; + pl.style.opacity = '0.95'; + pl.innerHTML = 'ไม่ได้รับการ์ดเกรด ' + gLetter + ' · ภารกิจไม่ผ่านเกณฑ์'; + bonusEl.appendChild(pl); } } ov.classList.remove('is-hidden'); @@ -7342,8 +7733,14 @@ jumpSurvivePlatformScrollPx = 0; const { cw, ch } = getCharacterFootprintWH(mapData); const ts = tileSize; - jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts; - jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts; + if (isJumpSurviveMissionUiMapPlay()) { + const w = mapData.width || 20, h = mapData.height || 15; + jumpSurviveCamCenterX = (w * ts) * 0.5; + jumpSurviveCamCenterY = (h * ts) * 0.5; + } else { + jumpSurviveCamCenterX = (me.x + cw * 0.5) * ts; + jumpSurviveCamCenterY = (me.y + ch * 0.5) * ts; + } } function jumpSurviveOverlapsObjectSolids(md, left, top, right, bottom) { @@ -7420,8 +7817,12 @@ } function jumpSurviveCameraTargetPx(md) { - const { cw, ch } = getCharacterFootprintWH(md); const ts = tileSize; + const w = md.width || 20, h = md.height || 15; + if (isJumpSurviveMissionUiMapPlay()) { + return { px: (w * ts) * 0.5, py: (h * ts) * 0.5 }; + } + const { cw, ch } = getCharacterFootprintWH(md); const cx = (e) => (e.x + cw * 0.5) * ts; const cy = (e) => (e.y + ch * 0.5) * ts; if (!jumpSurviveEliminated) return { px: cx(me), py: cy(me) }; @@ -7527,6 +7928,14 @@ function jumpSurviveTickFrame() { if (!mapData || !isJumpSurvive()) return; + if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) return; + const remEnd = jumpSurviveRemainingSecMission(); + if (remEnd !== null && remEnd <= 0) { + endJumpSurviveMissionRound(); + return; + } + } const md = mapData; const ts = tileSize; const now = performance.now(); @@ -7544,6 +7953,7 @@ if (jumpSurviveEliminated) { stepJumpSurvivePreviewBots(dt, halfViewH); + jumpSurviveMissionMaybeEarlyFinish(); return; } @@ -7592,6 +8002,8 @@ socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); } } + + jumpSurviveMissionMaybeEarlyFinish(); } function gridImageLibIdleUrlPlay(entry) { @@ -7907,6 +8319,7 @@ gauntletJumpVis: typeof p.gauntletJumpTicks === 'number' ? p.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(p.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), gauntletEliminated: !!p.gauntletEliminated, + jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(p.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(p.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { @@ -7969,7 +8382,24 @@ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; - jumpSurviveInitRuntime(); + jumpSurviveGameEnded = false; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + if (isJumpSurviveMissionUiMapPlay()) { + jumpSurviveMissionPhase = 'howto'; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (t) { if (t) applyGauntletTimingFromServer(t); }) + .catch(function () {}); + setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 50); + } else { + jumpSurviveMissionPhase = null; + jumpSurviveInitRuntime(); + } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; @@ -8011,6 +8441,15 @@ const gccR = document.getElementById('gauntlet-crown-countdown'); if (gccR) gccR.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50); + } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) { + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + const gccRj = document.getElementById('gauntlet-crown-countdown'); + if (gccRj) gccRj.classList.add('is-hidden'); + gauntletCrownPregamePhase = null; + gauntletCrownLobbyReadyMap = {}; } else { const hov = document.getElementById('gauntlet-crown-howto-overlay'); if (hov) { @@ -8105,6 +8544,7 @@ gauntletJumpTicks: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletJumpVis: typeof data.gauntletJumpTicks === 'number' ? data.gauntletJumpTicks : 0, gauntletScore: (() => { const s = Number(data.gauntletScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), + jumpSurviveEliminated: false, spaceShooterScore: (() => { const s = Number(data.spaceShooterScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossScore: (() => { const s = Number(data.balloonBossScore); return Number.isFinite(s) ? Math.max(0, s) : 0; })(), balloonBossBalloons: (() => { @@ -8527,9 +8967,12 @@ } } else { gauntletEndsAtMs = null; - const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); - if (hto3) hto3.classList.add('is-hidden'); - gauntletCrownHowtoVisible = false; + const skipHideCrownShell = mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay(); + if (!skipHideCrownShell) { + const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); + if (hto3) hto3.classList.add('is-hidden'); + gauntletCrownHowtoVisible = false; + } gauntletCrownPregamePhase = null; gauntletCrownLobbyReadyMap = {}; if (gauntletCrownCountdownTimer) { @@ -8571,7 +9014,24 @@ if (!mapData.jumpSurvivePlatformArea) mapData.jumpSurvivePlatformArea = []; normalizeJumpSurvivePlatformAreaInPlay(mapData); jumpSurviveEliminated = false; - jumpSurviveInitRuntime(); + jumpSurviveGameEnded = false; + if (jumperMissionCountdownTimer) { + clearTimeout(jumperMissionCountdownTimer); + jumperMissionCountdownTimer = null; + } + if (isJumpSurviveMissionUiMapPlay()) { + jumpSurviveMissionPhase = 'howto'; + hideConflictingOverlaysForJumpSurviveMission(); + applyJumpSurviveJumperPanelImages(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + setTimeout(function () { showJumpSurviveMissionHowtoOverlay(); }, 60); + } else { + jumpSurviveMissionPhase = null; + jumpSurviveInitRuntime(); + } } if (mapData.gameType === 'space_shooter') { spaceShooterGameEnded = false; @@ -9630,7 +10090,8 @@ ? 'QUIZ CARRY · COURT — หยิบป้ายถูกแล้วส่งที่ฮับ (F / Grab)' : (isGauntlet() ? 'GAUNTLET · SURVIVAL RUN' : (isSpaceShooter() ? 'SPACE SHOOTER · ARCADE' - : (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' : 'JUMP SURVIVE · NODE UPLINK'))); + : (isBalloonBoss() ? 'BALLOON BOSS · MEGA VIRUS' + : (isJumpSurviveMissionUiMapPlay() ? 'JUMPER · SURVIVE — เหลือเวลา (วินาที) · TIME (sec)' : 'JUMP SURVIVE · NODE UPLINK')))); } if (timeVal) { if (isQuizCarry()) { @@ -9677,6 +10138,13 @@ const rem = balloonBossRemainingSecPlay(); timeVal.textContent = rem != null ? String(rem) : '∞'; } + } else if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'live' || jumpSurviveGameEnded) { + timeVal.textContent = jumpSurviveMissionPhase === 'howto' ? '···' : (jumpSurviveMissionPhase === 'countdown' ? '···' : '0'); + } else { + const rem = jumpSurviveRemainingSecMission(); + timeVal.textContent = rem != null ? String(rem) : '∞'; + } } else { const t = jumpSurviveSessionStartMs > 0 ? Math.max(0, Math.floor((performance.now() - jumpSurviveSessionStartMs) / 1000)) @@ -9724,9 +10192,15 @@ ? 'ชุดคำถามจบแล้ว · Session ended — ดูคะแนนด้าน SCORE' : ('ยืนโซนตัวเลือกแล้วกด F/Grab หยิบ · ถือป้ายถูกแล้วไปฮับส่ง — ถูก +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' แต้ม (correct +' + QUIZ_CARRY_POINTS_PER_CORRECT + ' on hub)'); } else if (isJumpSurvive()) { - hintEl.textContent = jumpSurviveEliminated - ? 'Spectate remaining nodes · Exit room to reset session' - : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)'; + if (isJumpSurviveMissionUiMapPlay()) { + hintEl.textContent = jumpSurviveEliminated + ? 'คุณตกน้ำแล้ว — ดูเพื่อนต่อ — รอจบเพื่อรับคะแนน (ผู้รอดได้ 100 คะแนน)' + : 'กระโดดตามแท่น — หลีกหนาม/กับดักด้านล่าง · หมดเวลาแล้วสรุปคะแนน (ผู้รอด 100) · Space / W / ↑ กระโดด · A D / ← → เดิน'; + } else { + hintEl.textContent = jumpSurviveEliminated + ? 'Spectate remaining nodes · Exit room to reset session' + : 'Space / W / ↑ jump · A D / arrows move · Hit ceiling/floor = out (watch others)'; + } } else if (isSpaceShooter()) { hintEl.textContent = spaceShooterGameEnded ? 'จบแล้ว · Session ended — ดูอันดับบน overlay / see rankings on overlay' @@ -9758,6 +10232,7 @@ const ul = document.getElementById('play-cyber-score-list'); if (!ul) return; const safeYv = (v) => (typeof v === 'number' && !isNaN(v) ? v : 1); + const jumpMissionHud = isJumpSurvive() && isJumpSurviveMissionUiMapPlay(); const rows = []; rows.push({ id: myId, @@ -9766,7 +10241,8 @@ score: isQuizCarry() ? cyberHudQuizCarryPeerScore(myId) : (isGauntlet() ? (me.gauntletScore || 0) : (isSpaceShooter() ? (me.spaceShooterScore || 0) - : (isBalloonBoss() ? (me.balloonBossScore || 0) : 0))), + : (isBalloonBoss() ? (me.balloonBossScore || 0) + : (jumpMissionHud ? (jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(me.y), eliminated: !!(isJumpSurvive() && jumpSurviveEliminated) || !!(isBalloonBoss() && me.balloonBossEliminated) || !!(isGauntletCrownHeistMapPlay() && me.gauntletEliminated), @@ -9780,7 +10256,8 @@ score: isQuizCarry() ? cyberHudQuizCarryPeerScore(id) : (isGauntlet() ? (o.gauntletScore || 0) : (isSpaceShooter() ? (o.spaceShooterScore || 0) - : (isBalloonBoss() ? (o.balloonBossScore || 0) : 0))), + : (isBalloonBoss() ? (o.balloonBossScore || 0) + : (jumpMissionHud ? (o.jumpSurviveEliminated ? 0 : 100) : 0)))), y: safeYv(o.y), eliminated: !!(isJumpSurvive() && isPreviewBotId(id) && o.jumpSurviveEliminated) || !!(isBalloonBoss() && o.balloonBossEliminated) @@ -9789,7 +10266,7 @@ }); }); let leaderId = null; - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) { + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { let mx = -1; for (let i = 0; i < rows.length; i++) { if (rows[i].score > mx) { @@ -9807,7 +10284,11 @@ } } rows.sort((a, b) => { - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) return b.score - a.score; + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { + if (b.score !== a.score) return b.score - a.score; + if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; + return String(a.nickname || '').localeCompare(String(b.nickname || ''), 'th'); + } if (a.eliminated !== b.eliminated) return a.eliminated ? 1 : -1; return a.y - b.y; }); @@ -9856,7 +10337,7 @@ mid.appendChild(st); const sc = document.createElement('span'); sc.className = 'play-cyber-score-val'; - if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry()) { + if (isGauntlet() || isSpaceShooter() || isBalloonBoss() || isQuizCarry() || jumpMissionHud) { sc.textContent = String(row.score); } else { sc.textContent = row.eliminated ? '—' : String(r + 1); @@ -10652,6 +11133,10 @@ } } if (isJumpSurvive() && mapData && !isChatFocused()) { + if (isJumpSurviveMissionPregameBlockingPlay()) { + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); + return; + } if (jumpSurviveEliminated) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) e.preventDefault(); return; @@ -10720,6 +11205,12 @@ return; } if (isJumpSurvive()) { + if (isJumpSurviveMissionPregameBlockingPlay()) { + me.isWalking = false; + draw(); + requestAnimationFrame(tick); + return; + } jumpSurviveTickFrame(); draw(); requestAnimationFrame(tick); @@ -10935,6 +11426,13 @@ const btn = document.getElementById('btn-gch-ready'); if (!btn) return; btn.addEventListener('click', () => { + if (isJumpSurviveMissionUiMapPlay()) { + if (jumpSurviveMissionPhase !== 'howto') return; + const humans = quizCarryPregameHumanIds(); + if (!(humans.length === 1 || isMePlayHost())) return; + beginJumpSurviveMissionCountdownThenRun(); + return; + } if (!isGauntletCrownHeistMapPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null || !isMePlayHost()) return;