diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index 02da3cb..6629aff 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -29,6 +29,8 @@ const QUIZ_QUESTION_MISSION_MAP_ID = 'mng8a80o'; /** Stack ซ้อนตึก — ฉากภารกิจ (HOWTO → นับถอยหลัง → เล่น → สรุป) รูปใน `/Game/img/TowerBlock` */ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; + /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown / mno9kb07) รูปใน `/Game/img/MegaVirus` */ + const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; /** ครบชั้นนี้ขึ้นไป — ยกจุดแกว่ง/เชือกขึ้น (world px) ป้องกันชนกองสูง (เฉพาะ Tower mission) */ const STACK_TOWER_SWING_LIFT_FROM_LAYER = 9; /** Stack — เกณฑ์ใกล้ความจริง: ทับมากขึ้น, จำกัดการเยื้องจากกลางฐาน, ทับน้อยแล้วโคลงง่ายขึ้น */ @@ -3428,9 +3430,16 @@ function isGauntletCrownHeistMapPlay() { return isGauntletFaceRightMapMno9kb07(); } + function isMegaVirusMissionShellMapPlay() { + return !!(mapData && mapData.gameType === 'balloon_boss' && currentPlayMapId() === BALLOON_BOSS_MISSION_MAP_ID); + } + /** Crown gauntlet หรือ Mega Virus — lobby HOWTO / Ready / นับถอยหลัง / held run */ + function usesCrownLobbyShellPlay() { + return isGauntletCrownHeistMapPlay() || isMegaVirusMissionShellMapPlay(); + } let gauntletCrownHowtoVisible = false; function isGauntletCrownPregameBlockingPlay() { - if (!isGauntletCrownHeistMapPlay()) return false; + if (!usesCrownLobbyShellPlay()) return false; if (gauntletCrownRunHeldRemote) return true; if (gauntletCrownPregamePhase != null && gauntletCrownPregamePhase !== 'live') return true; return false; @@ -4227,6 +4236,29 @@ return BASE + '/img/ViolentCrime/' + String(file || '').replace(/^\//, ''); } + function megaVirusAssetUrl(file) { + return BASE + '/img/MegaVirus/' + String(file || '').replace(/^\//, ''); + } + + function applyMegaVirusMissionPanelImages() { + const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); + if (howtoBg) { + howtoBg.src = megaVirusAssetUrl('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 = megaVirusAssetUrl('popup-result.png'); + resBg.onerror = function () { + this.onerror = null; + this.src = BASE + '/img/gauntlet-assets/popup-result.png'; + }; + } + } + function applySpaceShooterMissionPanelImages() { const howtoBg = document.querySelector('#gauntlet-crown-howto-overlay .gch-bg'); if (howtoBg) { @@ -4475,6 +4507,149 @@ }; } + function balloonBossMergePreviewBotsMission(mission) { + if (!mission || mission.uiSkin !== 'mega_virus' || !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, + finalScore: Math.max(0, Number(r.finalScore != null ? r.finalScore : r.baseScore) || 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: Math.max(0, Number(o.balloonBossScore) || 0), + eliminated: !!o.balloonBossEliminated, + rankBonus: 0, + finalScore: Math.max(0, Number(o.balloonBossScore) || 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)); + }); + 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 n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + const progress = Math.min(1, totalSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + if (progress >= 1) grade = 'A'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: baseRows.filter(function (r) { return !r.eliminated; }).length, + participantCount: baseRows.length, + balloonBossEndReason: mission.balloonBossEndReason, + }; + } + + function balloonBossBuildMissionPayloadPlay(reason) { + const rows = []; + if (myId != null) { + rows.push({ + id: myId, + nickname: ((me.nickname || nick || 'คุณ').trim() || 'คุณ'), + characterId: me.characterId ?? null, + baseScore: Math.max(0, me.balloonBossScore | 0), + eliminated: !!me.balloonBossEliminated, + }); + } + others.forEach((o, id) => { + rows.push({ + id: id, + nickname: (o && o.nickname) ? String(o.nickname).trim() : id, + characterId: o && o.characterId != null ? o.characterId : null, + baseScore: Math.max(0, o.balloonBossScore | 0), + eliminated: !!(o && o.balloonBossEliminated), + }); + }); + rows.sort((a, b) => b.baseScore - a.baseScore + || String(a.nickname).localeCompare(String(b.nickname), 'th') + || String(a.id).localeCompare(String(b.id))); + const top = rows.slice(0, 5); + const ranked = top.map((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((r) => r.baseScore); + const totalSum = totalParts.reduce((s, n) => s + n, 0); + const n2 = ranked.length || 1; + const averageScore = Math.floor(totalSum / n2); + const maxHp = balloonBossMaxHpPlay(); + const progress = Math.min(1, totalSum / Math.max(1, maxHp)); + const score100 = Math.min(100, Math.floor(progress * 100)); + let grade = 'C'; + if (reason === 'victory' || progress >= 1) grade = 'A'; + else if (score100 >= 50) grade = 'B'; + else if (totalSum <= 0) grade = 'F'; + const rewardCard = grade === 'F' ? null : gauntletCrownRollRewardCardLocal(grade); + return { + ranked: ranked, + totalSum: totalSum, + averageScore: averageScore, + grade: grade, + rewardCard: rewardCard, + totalParts: totalParts, + uiSkin: 'mega_virus', + survivorCount: rows.filter((r) => !r.eliminated).length, + participantCount: rows.length, + balloonBossEndReason: reason, + }; + } + function endSpaceShooterMissionRound() { if (!isSpaceShooterMissionUiMapPlay() || spaceShooterGameEnded) return; spaceShooterGameEnded = true; @@ -7434,7 +7609,7 @@ } if (Object.prototype.hasOwnProperty.call(payload, 'gauntletCrownRunHeld')) { gauntletCrownRunHeldRemote = !!payload.gauntletCrownRunHeld; - if (!gauntletCrownRunHeldRemote && isGauntletCrownHeistMapPlay()) { + if (!gauntletCrownRunHeldRemote && usesCrownLobbyShellPlay()) { gauntletCrownPregamePhase = 'live'; } } @@ -7920,7 +8095,7 @@ function updateGauntletCrownHowtoHud() { const st = document.getElementById('gauntlet-crown-howto-status'); const btn = document.getElementById('btn-gch-ready'); - if (!btn || !isGauntletCrownHeistMapPlay() || gauntletCrownPregamePhase !== 'howto') return; + if (!btn || !usesCrownLobbyShellPlay() || gauntletCrownPregamePhase !== 'howto') return; const humans = quizCarryPregameHumanIds(); const tot = Math.max(1, quizCarryPregameTotalPlayers()); const num = gauntletCrownPregameReadyNumerator(); @@ -7939,7 +8114,7 @@ } function beginGauntletCrownCountdownThenRun() { - if (!isGauntletCrownHeistMapPlay()) return; + if (!usesCrownLobbyShellPlay()) return; if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); gauntletCrownCountdownTimer = null; @@ -7950,6 +8125,24 @@ gauntletCrownHowtoVisible = false; const cd = document.getElementById('gauntlet-crown-countdown'); const numEl = document.getElementById('gauntlet-crown-countdown-num'); + function applyMegaBalloonLiveStartPlay() { + balloonBossSessionStartMs = performance.now(); + balloonBossLastTickMs = performance.now(); + balloonBossGameEnded = false; + balloonBossPendingShots = []; + balloonBossPlayerBullets = []; + balloonBossBossBullets = []; + balloonBossBossFireAcc = 0; + me.balloonBossScore = 0; + me.balloonBossBalloons = balloonBossBalloonsStartPlay(); + me.balloonBossEliminated = false; + others.forEach((o) => { + o.balloonBossScore = 0; + o.balloonBossBalloons = balloonBossBalloonsStartPlay(); + o.balloonBossEliminated = false; + }); + if (mapData) applyBalloonBossSpawnLayoutPlay(); + } const runFinish = () => { if (cd) cd.classList.add('is-hidden'); if (gauntletCrownCountdownTimer) { @@ -7958,10 +8151,14 @@ } if (socket && socket.connected) { socket.emit('gauntlet-crown-begin-run', (res) => { - if (res && res.ok) gauntletCrownPregamePhase = 'live'; + if (res && res.ok) { + gauntletCrownPregamePhase = 'live'; + if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay(); + } }); } else { gauntletCrownPregamePhase = 'live'; + if (isMegaVirusMissionShellMapPlay()) applyMegaBalloonLiveStartPlay(); } }; if (!cd || !numEl) { @@ -7985,8 +8182,9 @@ } function showGauntletCrownHowtoOverlay() { - if (!isGauntletCrownHeistMapPlay()) return; + if (!usesCrownLobbyShellPlay()) return; hideConflictingOverlaysForGauntletCrown(); + if (isMegaVirusMissionShellMapPlay()) applyMegaVirusMissionPanelImages(); const cd = document.getElementById('gauntlet-crown-countdown'); if (cd) cd.classList.add('is-hidden'); if (gauntletCrownCountdownTimer) { @@ -8020,6 +8218,10 @@ const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } + if (mission.uiSkin === 'mega_virus') { + const g = String(mission.grade || '').trim().toUpperCase().charAt(0); + return g !== 'F'; + } const g = String(mission.grade || '').trim().toUpperCase().charAt(0); return g !== 'F'; } @@ -8053,7 +8255,7 @@ */ function gauntletCrownBuildDisplayMission(mission) { if (!mission || !Array.isArray(mission.ranked)) return mission; - if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower') return mission; + if (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus') return mission; if (!(previewFillBots && isGauntletCrownHeistMapPlay() && others && typeof others.forEach === 'function')) return mission; const seen = new Set(); @@ -8142,6 +8344,8 @@ disp = quizQuestionMissionMergePreviewBotsMission(disp) || disp; } else if (mission.uiSkin === 'stack_tower') { disp = stackTowerMissionMergePreviewBotsMission(disp) || disp; + } else if (mission.uiSkin === 'mega_virus') { + disp = balloonBossMergePreviewBotsMission(disp) || disp; } if (!disp || !Array.isArray(disp.ranked)) return; rowEl.innerHTML = ''; @@ -8206,10 +8410,11 @@ nick.textContent = r.nickname || '—'; const sc = document.createElement('div'); sc.className = 'gcm-sc'; - const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower'); + const jumpPlain = mission && (mission.uiSkin === 'jumper' || mission.uiSkin === 'violent_crime' || mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus'); const botScorePlain = (previewFillBots && isGauntletCrownHeistMapPlay() && isPreviewBotId(r.id)) || (previewFillBots && mission && mission.uiSkin === 'question_mission' && isPreviewBotId(r.id)) - || (previewFillBots && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id)); + || (previewFillBots && mission && mission.uiSkin === 'stack_tower' && isPreviewBotId(r.id)) + || (previewFillBots && mission && mission.uiSkin === 'mega_virus' && isPreviewBotId(r.id)); sc.textContent = (jumpPlain || botScorePlain) ? String(Math.max(0, Number(r.baseScore) || 0)) : String(r.baseScore || 0) + '(+' + String(r.rankBonus || 0) + ')'; @@ -8240,6 +8445,9 @@ } else if (mission && mission.uiSkin === 'stack_tower') { const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด'; + } else if (mission && mission.uiSkin === 'mega_virus') { + const pc = Math.max(0, Math.floor(Number(disp.participantCount) || 0)); + totalEl.innerHTML = 'ความเสียหายรวม (' + parts.join('+') + ') = ' + sum + ' · ผู้เล่น ' + pc + ' → เกรด'; } else { totalEl.innerHTML = 'คะแนนรวม (' + parts.join('+') + ') = ' + sum + ' · เฉลี่ย ' + avg + ' → เกรด'; } @@ -8260,9 +8468,10 @@ row.style.gap = '8px'; const chk = document.createElement('img'); const stackTS = mission && mission.uiSkin === 'stack_tower'; - chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png'); + const megaVs = mission && mission.uiSkin === 'mega_virus'; + chk.src = stackTS ? stackTowerAssetUrl('result-check.png') : (megaVs ? megaVirusAssetUrl('result-check.png') : (BASE + '/img/gauntlet-assets/result-check.png')); chk.alt = ''; - if (stackTS) { + if (stackTS || megaVs) { chk.onerror = function () { this.onerror = null; this.src = BASE + '/img/gauntlet-assets/result-check.png'; @@ -8295,7 +8504,7 @@ } const gcmHead = document.getElementById('gcm-heading'); if (gcmHead) { - if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower')) gcmHead.classList.add('sr-only'); + if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus')) gcmHead.classList.add('sr-only'); else gcmHead.classList.remove('sr-only'); } ov.classList.remove('is-hidden'); @@ -8309,7 +8518,7 @@ if (previewMode && editorEmbedReturn) { ov.classList.add('is-hidden'); if (gcmHead) gcmHead.classList.remove('sr-only'); - if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower')) { + if (mission && (mission.uiSkin === 'question_mission' || mission.uiSkin === 'stack_tower' || mission.uiSkin === 'mega_virus')) { cancelQuizCarryResultEndAfterTimeup(); hideQuizCarryTimeupOnDeskLayer(); hideQuizCarryResultEndLayer(); @@ -9447,6 +9656,8 @@ function balloonBossRemainingSecPlay() { const lim = balloonBossTimeLimitSecPlay(); if (lim <= 0) return null; + if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return lim; + if (!balloonBossSessionStartMs) return lim; const elapsed = (performance.now() - balloonBossSessionStartMs) / 1000; return Math.max(0, Math.ceil(lim - elapsed)); } @@ -9521,6 +9732,11 @@ balloonBossPendingShots = []; balloonBossPlayerBullets = []; balloonBossBossBullets = []; + if (isMegaVirusMissionShellMapPlay()) { + applyMegaVirusMissionPanelImages(); + showGauntletCrownMissionOverlay(balloonBossBuildMissionPayloadPlay(reason)); + return; + } const ov = document.getElementById('gauntlet-ended-overlay'); const msgEl = document.getElementById('gauntlet-ended-message'); const titleEl = document.getElementById('gauntlet-ended-title'); @@ -9597,6 +9813,7 @@ function balloonBossTickFrame() { if (!mapData || mapData.gameType !== 'balloon_boss') return; + if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) return; const w = mapData.width || 20, h = mapData.height || 15; const ts = tileSize; const mw = w * ts, mh = h * ts; @@ -10884,8 +11101,13 @@ if (mapData.gameType !== 'quiz_carry') { quizCarryWalkSpeedMultActive = QUIZ_CARRY_WALK_SPEED_MULT; } - if (res && res.mapId != null && String(res.mapId).trim() !== '') { - playSessionMapId = String(res.mapId).trim(); + { + const qMap = (playMapIdFromQuery || '').trim(); + const dataId = mapData && mapData.id != null && String(mapData.id).trim() !== '' ? String(mapData.id).trim() : ''; + const resMap = res && res.mapId != null && String(res.mapId).trim() !== '' ? String(res.mapId).trim() : ''; + if (qMap) playSessionMapId = qMap; + else if (dataId) playSessionMapId = dataId; + else if (resMap) playSessionMapId = resMap; } if (res && res.hostId != null) playHostId = res.hostId; if (!isQuizQuestionMissionUiMapPlay()) { @@ -11208,6 +11430,20 @@ const gccR = document.getElementById('gauntlet-crown-countdown'); if (gccR) gccR.classList.add('is-hidden'); setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50); + } else if (mapData.gameType === 'balloon_boss' && isMegaVirusMissionShellMapPlay()) { + hideConflictingOverlaysForGauntletCrown(); + gauntletCrownPregamePhase = null; + gauntletCrownLobbyReadyMap = {}; + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + const gccRm = document.getElementById('gauntlet-crown-countdown'); + if (gccRm) gccRm.classList.add('is-hidden'); + if (res && Object.prototype.hasOwnProperty.call(res, 'gauntletCrownRunHeld')) { + applyGauntletTimingFromServer({ gauntletCrownRunHeld: res.gauntletCrownRunHeld }); + } + setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 50); } else if (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) { if (gauntletCrownCountdownTimer) { clearTimeout(gauntletCrownCountdownTimer); @@ -11385,7 +11621,7 @@ } }); socket.on('gauntlet-crown-lobby-started', () => { - if (!isGauntletCrownHeistMapPlay()) return; + if (!usesCrownLobbyShellPlay()) return; beginGauntletCrownCountdownThenRun(); }); socket.on('user-move', (data) => { @@ -11573,7 +11809,12 @@ }); socket.on('gauntlet-sync', (data) => { - if (!mapData || mapData.gameType !== 'gauntlet' || !data) return; + if (!data || typeof data !== 'object') return; + if (isMegaVirusMissionShellMapPlay()) { + applyGauntletTimingFromServer(data); + return; + } + if (!mapData || mapData.gameType !== 'gauntlet') return; applyGauntletTimingFromServer(data); if (Array.isArray(data.obstacles)) { gauntletObstacles = data.obstacles; @@ -11795,10 +12036,12 @@ } } else { gauntletEndsAtMs = null; + const megaShellGs = mapData.gameType === 'balloon_boss' && String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID; const skipHideCrownShell = (mapData.gameType === 'jump_survive' && isJumpSurviveMissionUiMapPlay()) || (mapData.gameType === 'space_shooter' && isSpaceShooterMissionUiMapPlay()) || (mapData.gameType === 'quiz' && isQuizQuestionMissionUiMapPlay()) - || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay()); + || (mapData.gameType === 'stack' && isStackTowerMissionUiMapPlay()) + || megaShellGs; if (!skipHideCrownShell) { const hto3 = document.getElementById('gauntlet-crown-howto-overlay'); if (hto3) hto3.classList.add('is-hidden'); @@ -11927,7 +12170,6 @@ balloonBossHitFx = []; balloonBossLastTickMs = performance.now(); balloonBossPlayerFireCd = 0; - balloonBossSessionStartMs = performance.now(); balloonBossLastMoveEmit = 0; balloonBossBossFireAcc = 0; me.balloonBossScore = 0; @@ -11938,10 +12180,36 @@ o.balloonBossBalloons = balloonBossBalloonsStartPlay(); o.balloonBossEliminated = false; }); - fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) - .then((r) => (r.ok ? r.json() : null)) - .then((t) => { if (t) applyGauntletTimingFromServer(t); }) - .catch(() => {}); + if (String(ev.mapId || '').trim() === BALLOON_BOSS_MISSION_MAP_ID) { + balloonBossSessionStartMs = 0; + if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) { + applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld }); + } + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { + if (t) applyGauntletTimingFromServer(t); + if (Object.prototype.hasOwnProperty.call(ev, 'gauntletCrownRunHeld')) { + applyGauntletTimingFromServer({ gauntletCrownRunHeld: ev.gauntletCrownRunHeld }); + } + }) + .catch(() => {}); + gauntletCrownPregamePhase = null; + gauntletCrownLobbyReadyMap = {}; + if (gauntletCrownCountdownTimer) { + clearTimeout(gauntletCrownCountdownTimer); + gauntletCrownCountdownTimer = null; + } + const gccMv = document.getElementById('gauntlet-crown-countdown'); + if (gccMv) gccMv.classList.add('is-hidden'); + setTimeout(function () { showGauntletCrownHowtoOverlay(); }, 60); + } else { + balloonBossSessionStartMs = performance.now(); + fetch(BASE + '/api/game-timing?_=' + Date.now(), { cache: 'no-store' }) + .then((r) => (r.ok ? r.json() : null)) + .then((t) => { if (t) applyGauntletTimingFromServer(t); }) + .catch(() => {}); + } } if (mapData.gameType === 'quiz_battle') { if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = []; @@ -13124,7 +13392,9 @@ timeVal.textContent = rem != null ? String(rem) : '∞'; } } else if (isBalloonBoss()) { - if (balloonBossGameEnded) { + if (isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay()) { + timeVal.textContent = '···'; + } else if (balloonBossGameEnded) { timeVal.textContent = '0'; } else { const rem = balloonBossRemainingSecPlay(); @@ -14124,6 +14394,12 @@ } return; } + if (isBalloonBoss() && isMegaVirusMissionShellMapPlay() && isGauntletCrownPregameBlockingPlay() && mapData && !isChatFocused()) { + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { + e.preventDefault(); + } + return; + } if ((isSpaceShooter() || isBalloonBoss()) && mapData && !isChatFocused()) { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(e.code)) { e.preventDefault(); @@ -14500,7 +14776,7 @@ beginStackTowerMissionCountdownThenRun(); return; } - if (!isGauntletCrownHeistMapPlay()) return; + if (!usesCrownLobbyShellPlay()) return; if (gauntletCrownPregamePhase !== 'howto') return; if (myId == null || !isMePlayHost()) return; const humans = quizCarryPregameHumanIds(); diff --git a/www/html/Game/server.js b/www/html/Game/server.js index ff851df..4d3d73d 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -2895,11 +2895,43 @@ const GAUNTLET_LASER_SPAWN_CHANCE = 0.14; const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34; /** Last Light / museum gauntlet — scoring, mission UI, how-to flow */ const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07'; +/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */ +const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; function isGauntletCrownHeistMapBySpace(space) { return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); } +function isBalloonBossMissionShellSpace(space) { + if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false; + const md = (space.mapId && maps.get(space.mapId)) || space.mapData; + return !!(md && md.gameType === 'balloon_boss'); +} + +function isCrownLobbyShellSpace(space) { + return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space); +} + +function newBalloonBossShellGauntletRunState() { + return { + obstacles: [], + nextObsId: 1, + spawnAcc: 0, + nextSpawnIn: 999, + crownRunHeld: true, + timerId: null, + endsAt: null, + }; +} + +function emitBalloonBossMissionShellGauntletSync(sid, space) { + if (!isBalloonBossMissionShellSpace(space)) return; + const gr = space.gauntletRun; + io.to(sid).emit('gauntlet-sync', { + gauntletCrownRunHeld: !!(gr && gr.crownRunHeld), + }); +} + function rollGauntletCrownRewardCard(grade) { const r = Math.random(); if (grade === 'A') { @@ -3671,6 +3703,11 @@ io.on('connection', (socket) => { io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } startGauntletTicker(spaceId, space); + } else if (isBalloonBossMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.gauntletCrownLobbyReady[socket.id] = false; + if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); + io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } const peersList = [...space.peers.values()]; const mapData = mdJoin; @@ -3715,6 +3752,9 @@ io.on('connection', (socket) => { if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; } + if (isBalloonBossMissionShellSpace(space) && space.gauntletRun) { + joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; + } if (typeof cb === 'function') cb(joinCb); socket.to(spaceId).emit('user-joined', peer); if (space.voiceInits) { @@ -3977,6 +4017,13 @@ io.on('connection', (socket) => { gauntletEliminated: !!p.gauntletEliminated, })); } + if (md && isBalloonBossMissionShellSpace(space)) { + if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; + space.peers.forEach((_p, id) => { + space.gauntletCrownLobbyReady[id] = false; + }); + space.gauntletRun = newBalloonBossShellGauntletRunState(); + } } const gameStartPayload = { mapId: mapId || undefined, @@ -3985,6 +4032,10 @@ io.on('connection', (socket) => { peersSnap: peersSnap || undefined, }; if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; + if (mapId && maps.has(mapId) && isBalloonBossMissionShellSpace(space)) { + const grs = space.gauntletRun; + gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); + } io.to(sid).emit('game-start', gameStartPayload); return reply({ ok: true }); } @@ -4014,7 +4065,7 @@ io.on('connection', (socket) => { } if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { delete space.gauntletCrownLobbyReady[socket.id]; - if (isGauntletCrownHeistMapBySpace(space)) { + if (isCrownLobbyShellSpace(space)) { io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } } @@ -4057,14 +4108,20 @@ io.on('connection', (socket) => { const gr = space.gauntletRun; if (!gr) return reply({ ok: false }); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; - if (!md || md.gameType !== 'gauntlet' || !isGauntletCrownHeistMapBySpace(space)) return reply({ ok: false }); + const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space); + const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space); + if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false }); const previewRoom = !!(space.previewEditorTest || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' }); if (!gr.crownRunHeld) return reply({ ok: true, already: true }); gr.crownRunHeld = false; - ensureGauntletEndsAtIfNeeded(space); - emitGauntletSync(sid, space); + if (okGauntletCrown) { + ensureGauntletEndsAtIfNeeded(space); + emitGauntletSync(sid, space); + } else { + emitBalloonBossMissionShellGauntletSync(sid, space); + } return reply({ ok: true }); }); @@ -4134,7 +4191,7 @@ io.on('connection', (socket) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; - if (!isGauntletCrownHeistMapBySpace(space)) return; + if (!isCrownLobbyShellSpace(space)) return; if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') { space.gauntletCrownLobbyReady = {}; } @@ -4148,7 +4205,7 @@ io.on('connection', (socket) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; - if (!isGauntletCrownHeistMapBySpace(space)) return; + if (!isCrownLobbyShellSpace(space)) return; if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; for (const id of space.peers.keys()) { if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; @@ -4162,7 +4219,7 @@ io.on('connection', (socket) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); - if (!isGauntletCrownHeistMapBySpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown' }); + if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); const gr = space.gauntletRun; if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' });