minigame 7 Mega Virus 1.2

This commit is contained in:
2026-05-03 11:12:02 +00:00
parent ab08e27bac
commit eb6d8c9891
9 changed files with 527 additions and 59 deletions
+12
View File
@@ -11,6 +11,18 @@ server {
try_files $uri $uri/ =404;
}
# Browsers request /favicon.ico by default — serve branded SVG (no 404 noise)
location = /favicon.ico {
alias /var/www/html/favicon.svg;
default_type image/svg+xml;
access_log off;
}
# /Game/img/.../ with trailing slash = directory (no index) → was 403 Forbidden
location ~ ^/Game/img/(?!characters/).+/$ {
return 404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
+199
View File
@@ -19,6 +19,7 @@
/** สำรอง URL หลังอัปโหลดสำเร็จต่อช่อง — กัน race ที่ช่อง text ว่างตอน buildForSave แต่รูปอัปโหลดแล้วจริง */
var quizCarryPlaquePendingUrlBySlot = {};
var stackGameSaveInFlight = false;
var megaVirusSaveInFlight = false;
/** โหมดแทนที่รูป: ชื่อไฟล์เป้าหมายก่อนเปิด file picker */
var gauntletReplaceTarget = null;
var cred = { credentials: 'include' };
@@ -3348,6 +3349,202 @@
});
}
function encodeSpacesInUrlPath(t) {
var s = String(t || '');
var q = s.indexOf('?');
var pathPart = q === -1 ? s : s.slice(0, q);
var query = q === -1 ? '' : s.slice(q);
return pathPart.replace(/ /g, '%20') + query;
}
/** ให้สอดคล้องกับ server sanitizeGauntletAssetUrl + กัน placeholder .... */
function normalizeMegaVirusAssetUrl(v) {
var t = v != null ? String(v).trim().replace(/\/+$/, '') : '';
if (!t || t.length > 500) return '';
if (/\.{4,}/.test(t)) return '';
if (/[\t\n\r<>"'`]/.test(t)) return '';
if (/^https?:\/\//i.test(t)) return encodeSpacesInUrlPath(t);
if (/^Game\//i.test(t)) return encodeSpacesInUrlPath('/' + t.replace(/^\/+/, ''));
if (t.charAt(0) === '/') return encodeSpacesInUrlPath(t);
return '';
}
function updateMegaVirusBossPreview() {
var img = el('mega-virus-boss-prev');
var inp = el('mega-virus-boss-url');
if (!img) return;
var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : '');
if (!v) {
img.removeAttribute('src');
img.alt = '';
return;
}
img.alt = 'Boss preview';
img.src = v;
}
function updateMegaVirusBalloonPreview(slot) {
var img = el('mega-virus-balloon-prev-' + slot);
var inp = el('mega-virus-balloon-url-' + slot);
if (!img) return;
var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : '');
if (!v) {
img.removeAttribute('src');
img.alt = '';
return;
}
img.alt = 'Balloon P' + slot;
img.src = v;
}
function updateMegaVirusBalloonFallbackPreview() {
var img = el('mega-virus-balloon-fallback-prev');
var inp = el('mega-virus-balloon-fallback-url');
if (!img) return;
var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : '');
if (!v) {
img.removeAttribute('src');
img.alt = '';
return;
}
img.alt = 'Fallback balloon';
img.src = v;
}
function readMegaVirusBalloonUrlsFromForm() {
var urls = [];
for (var i = 1; i <= 6; i++) {
var inp = el('mega-virus-balloon-url-' + i);
urls.push(normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : ''));
}
return urls;
}
function bindMegaVirusPanel() {
var formMv = el('form-mega-virus-timing');
if (formMv) {
formMv.addEventListener('submit', function (e) {
e.preventDefault();
saveMegaVirusTimingPanel();
});
}
var bossInp = el('mega-virus-boss-url');
if (bossInp) {
bossInp.addEventListener('change', updateMegaVirusBossPreview);
bossInp.addEventListener('blur', updateMegaVirusBossPreview);
}
var bossClr = el('btn-mega-virus-boss-clear');
if (bossClr) {
bossClr.addEventListener('click', function () {
var i = el('mega-virus-boss-url');
if (i) i.value = '';
updateMegaVirusBossPreview();
});
}
for (var s = 1; s <= 6; s++) {
(function (slot) {
var inp = el('mega-virus-balloon-url-' + slot);
var btn = el('btn-mega-virus-balloon-clear-' + slot);
if (inp) {
inp.addEventListener('change', function () { updateMegaVirusBalloonPreview(slot); });
inp.addEventListener('blur', function () { updateMegaVirusBalloonPreview(slot); });
}
if (btn) {
btn.addEventListener('click', function () {
var i = el('mega-virus-balloon-url-' + slot);
if (i) i.value = '';
updateMegaVirusBalloonPreview(slot);
});
}
})(s);
}
var fbInp = el('mega-virus-balloon-fallback-url');
if (fbInp) {
fbInp.addEventListener('change', updateMegaVirusBalloonFallbackPreview);
fbInp.addEventListener('blur', updateMegaVirusBalloonFallbackPreview);
}
var fbClr = el('btn-mega-virus-balloon-fallback-clear');
if (fbClr) {
fbClr.addEventListener('click', function () {
var i = el('mega-virus-balloon-fallback-url');
if (i) i.value = '';
updateMegaVirusBalloonFallbackPreview();
});
}
}
function applyMegaVirusPanelFromTimingData(data) {
if (!data || typeof data !== 'object') return;
var inpT = el('mega-virus-mission-sec');
if (inpT && Object.prototype.hasOwnProperty.call(data, 'balloonBossMissionTimeSec')) {
var bb = Number(data.balloonBossMissionTimeSec);
inpT.value = String(Number.isFinite(bb) && bb > 0 ? Math.floor(bb) : 0);
}
var bossInp = el('mega-virus-boss-url');
if (bossInp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBossImageUrl')) {
bossInp.value = data.balloonBossBossImageUrl != null ? String(data.balloonBossBossImageUrl) : '';
updateMegaVirusBossPreview();
}
if (Array.isArray(data.balloonBossPlayerBalloonImageUrls)) {
var urls = data.balloonBossPlayerBalloonImageUrls;
for (var k = 1; k <= 6; k++) {
var inpU = el('mega-virus-balloon-url-' + k);
if (inpU) inpU.value = urls[k - 1] != null ? String(urls[k - 1]) : '';
updateMegaVirusBalloonPreview(k);
}
}
var fb = el('mega-virus-balloon-fallback-url');
if (fb && Object.prototype.hasOwnProperty.call(data, 'balloonBossPlayerBalloonFallbackUrl')) {
fb.value = data.balloonBossPlayerBalloonFallbackUrl != null ? String(data.balloonBossPlayerBalloonFallbackUrl) : '';
updateMegaVirusBalloonFallbackPreview();
}
}
function loadMegaVirusPanel() {
gameTimingFetch('GET')
.then(function (data) {
applyMegaVirusPanelFromTimingData(data);
setMsg('mega-virus-timing-msg', '', '');
})
.catch(function (e) {
setMsg('mega-virus-timing-msg', e.message || 'โหลดไม่ได้', 'error');
});
}
function saveMegaVirusTimingPanel() {
if (megaVirusSaveInFlight) return;
megaVirusSaveInFlight = true;
var btn = el('btn-mega-virus-save');
if (btn) btn.disabled = true;
var limSs = el('mega-virus-mission-sec') ? parseInt(String(el('mega-virus-mission-sec').value), 10) : 0;
if (Number.isNaN(limSs) || limSs < 0) limSs = 0;
limSs = limSs <= 0 ? 0 : Math.max(10, Math.min(7200, limSs));
var bossUrl = normalizeMegaVirusAssetUrl(el('mega-virus-boss-url') && el('mega-virus-boss-url').value ? String(el('mega-virus-boss-url').value).trim() : '');
var balloonUrls = readMegaVirusBalloonUrlsFromForm();
var fbUrl = normalizeMegaVirusAssetUrl(el('mega-virus-balloon-fallback-url') && el('mega-virus-balloon-fallback-url').value
? String(el('mega-virus-balloon-fallback-url').value).trim()
: '');
gameTimingFetch('GET')
.then(function (data) {
data.balloonBossMissionTimeSec = limSs;
data.balloonBossBossImageUrl = bossUrl;
data.balloonBossPlayerBalloonImageUrls = balloonUrls;
data.balloonBossPlayerBalloonFallbackUrl = fbUrl;
return gameTimingFetch('PUT', data);
})
.then(function (res) {
if (res && typeof res === 'object') applyMegaVirusPanelFromTimingData(res);
setMsg('mega-virus-timing-msg', 'บันทึกแล้ว — รีเฟรชหน้าเล่น (หรือเปิดใหม่) เพื่อโหลดรูป · Saved; hard-refresh play.', 'ok');
})
.catch(function (e) {
setMsg('mega-virus-timing-msg', e.message || 'บันทึกไม่ได้', 'error');
})
.then(function () {
megaVirusSaveInFlight = false;
if (btn) btn.disabled = false;
});
}
function saveJumpSurviveTimingPanel() {
var inp = el('jump-survive-height-mult');
var jumpSurvMult = inp ? parseFloat(String(inp.value)) : 1.5;
@@ -3444,6 +3641,7 @@
if (name === 'quiz-battle') loadQbBattlePanel();
if (name === 'jump-survive') loadJumpSurviveTimingPanel();
if (name === 'space-shooter') loadSpaceShooterTimingPanel();
if (name === 'mega-virus') loadMegaVirusPanel();
if (name === 'game-timing') loadGameTimingPanel();
if (name === 'stack-game') loadStackGamePanel();
}
@@ -3577,6 +3775,7 @@
bindStackBlockVisualInputs();
bindSpaceShooterDamageOverlayPanel();
bindSpaceShooterAsteroidSpritePanel();
bindMegaVirusPanel();
var btnStackGameSave = el('btn-stack-game-save');
if (btnStackGameSave) {
btnStackGameSave.addEventListener('click', saveStackGamePanel);
+36 -3
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#0b0d14">
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
<title>Admin — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -597,8 +598,40 @@
</section>
<section id="tab-panel-mega-virus" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-mega-virus">
<h2>Minigame-7 — ยิง Mega Virus</h2>
<p class="muted">จองเมนูไว้สำหรับตั้งค่ามินิเกมนี้ในอนาคต (ยังไม่มีฟอร์ม) · <em>English:</em> Placeholder tab — admin options will be added later.</p>
<h2>Minigame-7 — ยิง Mega Virus (balloon_boss)</h2>
<p class="muted">เก็บที่ <code>/Game/data/game-timing.json</code> · แมป <code>balloonBossTimeSec</code> ทับค่าด้านล่าง · แมป = 0 = ไม่จับเวลา · ถ้าแมปไม่ตั้งเวลา: ใช้ช่องล่าง (หรือ 120 วิ ถ้าใส่ 0) · <em>English:</em> Map time overrides; map 0 = unlimited; else global seconds below (or 120s if 0).</p>
<form id="form-mega-virus-timing" class="mega-virus-timing-form" action="#" method="post">
<fieldset class="quiz-timing-fieldset">
<legend>เวลา &amp; รูป</legend>
<div class="form-grid form-inline quiz-timing-grid">
<label title="0 = ใช้ 120 วิเมื่อแมปไม่กำหนด · ตั้งอย่างน้อย 10 ถ้าต้องการกำหนดเอง">เวลารอบ (วินาที) <input type="number" id="mega-virus-mission-sec" min="0" max="7200" step="5" value="0"></label>
</div>
<div class="form-grid form-inline" style="margin-top:0.75rem;align-items:flex-end;gap:0.75rem;flex-wrap:wrap">
<label class="space-shooter-ship-url-label" style="flex:1;min-width:14rem">รูปบอส (URL) <input type="text" id="mega-virus-boss-url" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/boss.png" autocomplete="off"></label>
<img class="space-shooter-ship-prev" id="mega-virus-boss-prev" alt="" width="64" height="64" decoding="async">
<button type="button" class="btn btn-ghost" id="btn-mega-virus-boss-clear">ล้าง</button>
</div>
<p class="muted" style="margin-top:0.35rem">ลูกโป่งต่อที่นั่ง P1–P6 · ช่องว่างใช้ fallback · <em>English:</em> Per-seat balloon URLs; empty uses fallback.</p>
<div class="space-shooter-ship-grid" role="group" aria-label="Mega Virus player balloon images">
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P1</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-1" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-1.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-1" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-1">ล้าง</button></div>
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P2</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-2" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-2.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-2" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-2">ล้าง</button></div>
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P3</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-3" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-3.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-3" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-3">ล้าง</button></div>
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P4</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-4" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-4.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-4" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-4">ล้าง</button></div>
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P5</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-5" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-5.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-5" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-5">ล้าง</button></div>
<div class="space-shooter-ship-row"><span class="space-shooter-ship-slot">P6</span><label class="space-shooter-ship-url-label">URL <input type="text" id="mega-virus-balloon-url-6" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-6.png" autocomplete="off"></label><img class="space-shooter-ship-prev" id="mega-virus-balloon-prev-6" alt="" width="40" height="48" decoding="async"><button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-clear-6">ล้าง</button></div>
</div>
<p class="muted" style="margin-top:0.5rem">Fallback ใช้เมื่อช่อง P ว่าง <strong>หรือ</strong> รูปต่อที่นั่งโหลดไม่สำเร็จ (404) · ห้ามใส่จุดสี่จุดจาก placeholder (<code>....</code>) — ต้องเป็น path ไฟล์จริง · <em>English:</em> Fallback when a P slot is empty <strong>or</strong> its image fails to load; <code>....</code> is rejected.</p>
<div class="form-grid form-inline" style="margin-top:0.75rem;align-items:flex-end;gap:0.75rem;flex-wrap:wrap">
<label class="space-shooter-ship-url-label" style="flex:1;min-width:14rem">รูปลูกโป่งร่วม (fallback) <input type="text" id="mega-virus-balloon-fallback-url" maxlength="500" spellcheck="false" placeholder="/Game/img/MegaVirus/balloon-default.png" autocomplete="off"></label>
<img class="space-shooter-ship-prev" id="mega-virus-balloon-fallback-prev" alt="" width="40" height="48" decoding="async">
<button type="button" class="btn btn-ghost" id="btn-mega-virus-balloon-fallback-clear">ล้าง</button>
</div>
</fieldset>
<div class="quiz-admin-actions" style="margin-top:0.75rem">
<button type="submit" class="btn btn-primary" id="btn-mega-virus-save">บันทึก</button>
</div>
</form>
<p id="mega-virus-timing-msg" class="msg" role="status"></p>
</section>
<section id="tab-panel-game-timing" class="tab-panel card" hidden role="tabpanel" aria-labelledby="tab-game-timing">
@@ -862,6 +895,6 @@
</div>
</main>
</div>
<script src="admin.js?v=61"></script>
<script src="admin.js?v=66"></script>
</body>
</html>
+28 -3
View File
@@ -19,8 +19,22 @@
"stackTowerMissionTimeSec": 90,
"stackTeamMissesMax": 3,
"stackTowerProgressBlocks": 50,
"stackBlockNormalImageUrls": ["", "", "", "", "", ""],
"stackBlockHeavyImageUrls": ["", "", "", "", "", ""],
"stackBlockNormalImageUrls": [
"",
"",
"",
"",
"",
""
],
"stackBlockHeavyImageUrls": [
"",
"",
"",
"",
"",
""
],
"stackHeavyBlockPercent": 35,
"jumpSurviveJumpHeightMult": 1.5,
"jumpSurviveMissionTimeSec": 0,
@@ -44,5 +58,16 @@
"/Game/img/ViolentCrime/Rocket-broke-1.png",
"/Game/img/ViolentCrime/Rocket-broke-2.png",
"/Game/img/ViolentCrime/Rocket-broke-3.png"
]
],
"balloonBossMissionTimeSec": 0,
"balloonBossBossImageUrl": "/Game/img/MegaVirus/boss.png",
"balloonBossPlayerBalloonImageUrls": [
"/Game/img/MegaVirus/balloon-1.png",
"/Game/img/MegaVirus/balloon-2.png",
"/Game/img/MegaVirus/balloon-3.png",
"/Game/img/MegaVirus/balloon-4.png",
"/Game/img/MegaVirus/balloon-5.png",
"/Game/img/MegaVirus/balloon-6.png"
],
"balloonBossPlayerBalloonFallbackUrl": "/Game/img/MegaVirus/Artboard%209.png"
}
@@ -12,3 +12,11 @@ English: Upload balloon-boss / Mega Virus assets under **`MegaVirus`** (no space
โฟลเดอร์นี้ถูกสร้างใน repo + บนเซิร์ฟจาก `scripts/deploy-www.sh` (mkdir + www-data + setgid 2775)
ถ้า FTP ยัง 550: ตรวจว่าเชื่อมที่โฟลเดอร์ **public/img** ใต้ document root (เช่น `/var/www/html/Game/public/img/MegaVirus`) และสิทธิ์กลุ่ม `www-data`
ใน Admin ใส่ URL เป็น **ไฟล์** เช่น `/Game/img/MegaVirus/balloon-1.png` — **อย่าลงท้ายด้วย /** (`/Game/img/MegaVirus/` จะได้ 403/404 เพราะเป็นโฟลเดอร์ ไม่ใช่รูป)
English: Use a **file** URL in Admin (e.g. `.../balloon-1.png`). A **trailing slash** points at a **folder**, not an image (403/404).
ชื่อไฟล์มี **ช่องว่าง** (เช่น `Artboard 9.png`) ใช้ได้ — ระบบจะเก็บ/โหลดเป็น `%20` อัตโนมัติ
English: Filenames with **spaces** are OK; URLs are normalized to `%20` for the browser.
+170 -48
View File
@@ -1237,6 +1237,11 @@
let playSpaceShooterAsteroidIntervalMs = 1040;
/** ทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (PNG โปร่ง) — ภารกิจ Violent Crime */
let playSpaceShooterShipDamageOverlayUrls = ['', '', ''];
/** balloon_boss / Mega Virus — จาก game-timing */
let playBalloonBossMissionTimeSec = 0;
let playBalloonBossBossImageUrl = '';
let playBalloonBossPlayerBalloonImageUrls = ['', '', '', '', '', ''];
let playBalloonBossPlayerBalloonFallbackUrl = '';
/** 0 = ไม่จำกัด — จาก game-timing / gauntlet-sync (พรมแดง Gauntlet เท่านั้น) */
let gauntletRuntimeTimeLimitSec = 0;
/** เวลาสิ้นสุดรอบ (epoch ms) จากเซิร์ฟเวอร์ — null = ไม่จับเวลา */
@@ -1258,19 +1263,31 @@
let gauntletLaserLineWidthPx = 2;
const gauntletAssetImageCache = new Map();
function encodeSpacesInUrlPath(t) {
const s = String(t || '');
const q = s.indexOf('?');
const pathPart = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
return pathPart.replace(/ /g, '%20') + query;
}
/** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
function normalizeGauntletAssetUrlForPlay(u) {
if (typeof u !== 'string') return '';
const t = u.trim();
if (!t) return '';
if (/^https?:\/\//i.test(t)) return t;
const qIdx = t.indexOf('?');
const base = (qIdx >= 0 ? t.slice(0, qIdx) : t).trim();
const qs = qIdx >= 0 ? t.slice(qIdx) : '';
const raw = u.trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
const z = raw.replace(/\/+$/, '');
return z ? encodeSpacesInUrlPath(z) : '';
}
const qIdx = raw.indexOf('?');
const base = (qIdx >= 0 ? raw.slice(0, qIdx) : raw).trim().replace(/\/+$/, '');
const qs = qIdx >= 0 ? raw.slice(qIdx) : '';
if (!base) return '';
if (base.startsWith('/')) return base + qs;
if (/^Game\//i.test(base)) return '/' + base.replace(/^\/+/, '') + qs;
return base + qs;
if (/\.{4,}/.test(base)) return '';
if (base.startsWith('/')) return encodeSpacesInUrlPath(base + qs);
if (/^Game\//i.test(base)) return encodeSpacesInUrlPath('/' + base.replace(/^\/+/, '') + qs);
return encodeSpacesInUrlPath(base + qs);
}
function ensureGauntletAssetImage(url) {
@@ -1289,6 +1306,21 @@
return rec;
}
/** ลูกโป่ง Mega Virus: ถ้ามี URL ต่อที่นั่งแต่โหลดไม่สำเร็จ (404) ให้ใช้ fallback แทน · Per-seat URL wins only when image loads */
function resolveBalloonBossBalloonSpriteRec(perSeatRaw, fallbackRaw) {
const per = normalizeGauntletAssetUrlForPlay(String(perSeatRaw || '').trim());
const fb = normalizeGauntletAssetUrlForPlay(String(fallbackRaw || '').trim());
if (per) {
const rPer = ensureGauntletAssetImage(per);
const img = rPer && rPer.img;
if (img && img.complete && img.naturalWidth > 0) return rPer;
if (img && img.complete && img.naturalWidth <= 0 && fb) return ensureGauntletAssetImage(fb);
return rPer;
}
if (fb) return ensureGauntletAssetImage(fb);
return null;
}
function pickGauntletLaneImageRec(obsId) {
if (!gauntletLaneImageUrls.length) return null;
const n = gauntletLaneImageUrls.length;
@@ -2932,6 +2964,10 @@
}
if (mapData.gameType === 'balloon_boss') {
applyBalloonBossSpawnLayoutPlay();
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 (quizCarryPregameActive && mapData && mapData.gameType === 'quiz_carry') updateQuizCarryPregameHud();
}
@@ -7532,6 +7568,38 @@
}
playSpaceShooterShipDamageOverlayUrls = nextD;
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossMissionTimeSec')) {
const st = Number(payload.balloonBossMissionTimeSec);
if (Number.isFinite(st) && st > 0) {
playBalloonBossMissionTimeSec = Math.max(10, Math.min(7200, Math.floor(st)));
} else {
playBalloonBossMissionTimeSec = 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossBossImageUrl')) {
playBalloonBossBossImageUrl = normalizeGauntletAssetUrlForPlay(
typeof payload.balloonBossBossImageUrl === 'string' ? payload.balloonBossBossImageUrl : ''
);
if (playBalloonBossBossImageUrl) ensureGauntletAssetImage(playBalloonBossBossImageUrl);
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonImageUrls')) {
const a = payload.balloonBossPlayerBalloonImageUrls;
const nextBb = ['', '', '', '', '', ''];
if (Array.isArray(a)) {
for (let si = 0; si < 6; si++) {
const u = normalizeGauntletAssetUrlForPlay(typeof a[si] === 'string' ? a[si] : '');
nextBb[si] = u;
if (u) ensureGauntletAssetImage(u);
}
}
playBalloonBossPlayerBalloonImageUrls = nextBb;
}
if (Object.prototype.hasOwnProperty.call(payload, 'balloonBossPlayerBalloonFallbackUrl')) {
playBalloonBossPlayerBalloonFallbackUrl = normalizeGauntletAssetUrlForPlay(
typeof payload.balloonBossPlayerBalloonFallbackUrl === 'string' ? payload.balloonBossPlayerBalloonFallbackUrl : ''
);
if (playBalloonBossPlayerBalloonFallbackUrl) ensureGauntletAssetImage(playBalloonBossPlayerBalloonFallbackUrl);
}
if (isStack() && stackMini) reapplyStackMiniSizingFromGlobals();
}
@@ -9327,6 +9395,8 @@
const t = Number(mapData && mapData.balloonBossTimeSec);
if (Number.isFinite(t) && t === 0) return 0;
if (Number.isFinite(t) && t > 0 && t < 7200) return Math.floor(t);
const g = Number(playBalloonBossMissionTimeSec);
if (Number.isFinite(g) && g > 0 && g < 7200) return Math.floor(g);
return 120;
}
@@ -9383,6 +9453,7 @@
cy = Math.min(mh - ts * 1.2, (h - 1.2) * ts);
}
const cl = clampBalloonBossWorld(cx, cy, mw, mh);
ent.balloonBossSkinSlot = slot;
ent.balloonBossCx = cl.cx;
ent.balloonBossCy = cl.cy;
if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
@@ -9708,6 +9779,8 @@
});
return best;
})();
const bossImgUrl = normalizeGauntletAssetUrlForPlay(String(playBalloonBossBossImageUrl || ''));
const bossImgRec = bossImgUrl ? ensureGauntletAssetImage(bossImgUrl) : null;
for (let hi = 0; hi < balloonBossHitFx.length; hi++) {
const fx = balloonBossHitFx[hi];
@@ -9726,37 +9799,58 @@
const [bsx, bsy] = worldToScreen(bossC.cx, bossC.cy);
ctx.save();
ctx.translate(bsx, bsy);
ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)';
ctx.lineWidth = Math.max(2, 2.5 * zDraw);
ctx.beginPath();
for (let k = 0; k < 6; k++) {
const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(ang) * bossR * zDraw * 1.05;
const py = Math.sin(ang) * bossR * zDraw * 1.05;
if (k === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
ctx.fill();
const limR = bossR * zDraw;
if (bossImgRec && bossImgRec.ready && bossImgRec.img && bossImgRec.img.naturalWidth > 0) {
const iw = bossImgRec.img.naturalWidth;
const ih = bossImgRec.img.naturalHeight;
const diam = limR * 2.1;
const scale = Math.min(diam / iw, diam / ih);
const dw = iw * scale;
const dh = ih * scale;
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(bossImgRec.img, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)';
ctx.lineWidth = Math.max(2, 2.5 * zDraw);
ctx.beginPath();
ctx.arc(0, 0, limR * 1.05, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeStyle = 'rgba(0, 255, 255, 0.75)';
ctx.lineWidth = Math.max(2, 2.5 * zDraw);
ctx.beginPath();
for (let k = 0; k < 6; k++) {
const ang = (k / 6) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(ang) * bossR * zDraw * 1.05;
const py = Math.sin(ang) * bossR * zDraw * 1.05;
if (k === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = 'rgba(0, 40, 60, 0.35)';
ctx.fill();
const skullR = bossR * zDraw * 0.62;
const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(0, 0, skullR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
ctx.shadowBlur = 12 * zDraw;
ctx.beginPath();
ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
const skullR = bossR * zDraw * 0.62;
const grd = ctx.createRadialGradient(-skullR * 0.2, -skullR * 0.25, skullR * 0.1, 0, 0, skullR);
grd.addColorStop(0, 'rgba(55, 50, 62, 0.98)');
grd.addColorStop(1, 'rgba(22, 18, 30, 1)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(0, 0, skullR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 40, 60, 0.95)';
ctx.shadowColor = 'rgba(255, 0, 0, 0.8)';
ctx.shadowBlur = 12 * zDraw;
ctx.beginPath();
ctx.arc(-skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.arc(skullR * 0.32, -skullR * 0.12, skullR * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.fillStyle = 'rgba(255, 100, 200, 0.9)';
ctx.font = `bold ${Math.max(10, 11 * zDraw)}px ui-monospace, monospace`;
@@ -9772,18 +9866,35 @@
ctx.strokeRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
ctx.restore();
function drawBalloonsCluster(sx, sy, count, col, z) {
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6) {
const slotIdx = Math.max(0, Math.min(5, (Math.floor(Number(slot1to6)) || 1) - 1));
const perSeat = String((playBalloonBossPlayerBalloonImageUrls[slotIdx] || '')).trim();
const fallback = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
const bRec = resolveBalloonBossBalloonSpriteRec(perSeat, fallback);
const n = Math.max(0, Math.min(8, count | 0));
for (let b = 0; b < n; b++) {
const ox = (b - (n - 1) / 2) * 7 * z;
const oy = -18 * z - Math.abs(b - (n - 1) / 2) * 2 * z;
ctx.fillStyle = col;
ctx.beginPath();
ctx.ellipse(sx + ox, sy + oy, 6.5 * z, 8 * z, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
const bx = sx + ox;
const by = sy + oy;
if (bRec && bRec.ready && bRec.img && bRec.img.naturalWidth > 0) {
const iw = bRec.img.naturalWidth;
const ih = bRec.img.naturalHeight;
const tw = 13 * z;
const th = 16 * z;
const sc = Math.min(tw / iw, th / ih);
const dw = iw * sc;
const dh = ih * sc;
ctx.drawImage(bRec.img, bx - dw / 2, by - dh / 2, dw, dh);
} else {
ctx.fillStyle = col;
ctx.beginPath();
ctx.ellipse(bx, by, 6.5 * z, 8 * z, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
@@ -9800,7 +9911,10 @@
ctx.beginPath();
ctx.arc(sx, sy, 22 * z, 0, Math.PI * 2);
ctx.stroke();
drawBalloonsCluster(sx, sy, ent.balloonBossBalloons | 0, col, z);
const skinSlot = (typeof ent.balloonBossSkinSlot === 'number' && ent.balloonBossSkinSlot >= 1 && ent.balloonBossSkinSlot <= 6)
? ent.balloonBossSkinSlot
: ((idx % 6) + 1);
drawBalloonsCluster(sx, sy, ent.balloonBossBalloons | 0, col, z, skinSlot);
const nm = String(ent.nickname || '').slice(0, 12);
ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
@@ -10894,6 +11008,10 @@
balloonBossSessionStartMs = performance.now();
balloonBossLastMoveEmit = 0;
balloonBossBossFireAcc = 0;
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 () {});
}
if (isQuiz()) {
setupPlayQuizUi();
@@ -11656,6 +11774,10 @@
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 (mapData.gameType === 'quiz_battle') {
if (!mapData.quizBattleDomeArea) mapData.quizBattleDomeArea = [];
+1 -1
View File
@@ -2009,7 +2009,7 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0184"></script>
<script src="js/play.js?v=0.277"></script>
<script src="js/play.js?v=0.283"></script>
<div class="version-tag">v —</div>
</body>
</html>
+61 -4
View File
@@ -614,6 +614,11 @@ function defaultGameTiming() {
/** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
spaceShooterAsteroidIntervalMs: 1040,
spaceShooterShipDamageOverlayUrls: ['', '', ''],
/** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */
balloonBossMissionTimeSec: 0,
balloonBossBossImageUrl: '',
balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''],
balloonBossPlayerBalloonFallbackUrl: '',
};
}
@@ -795,12 +800,26 @@ function defaultGauntletVisuals() {
};
}
/** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */
function encodeSpacesInAssetUrlPath(t) {
const s = String(t || '');
const q = s.indexOf('?');
const pathPart = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
return pathPart.replace(/ /g, '%20') + query;
}
function sanitizeGauntletAssetUrl(s) {
const t = String(s || '').trim();
const t = String(s || '').trim().replace(/\/+$/, '');
if (!t || t.length > 500) return '';
if (/[\s<>"'`]/.test(t)) return '';
if (t.startsWith('/')) return t;
if (/^https?:\/\//i.test(t)) return t;
/** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */
if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return '';
/** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */
if (/\.{4,}/.test(t)) return '';
if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t);
if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t);
/** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */
if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`);
return '';
}
@@ -901,6 +920,16 @@ function loadGameTiming() {
? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs)
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT,
spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls),
balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec')
? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec)
: 0,
balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl)
: '',
balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls),
balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl)
: '',
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
@@ -983,6 +1012,30 @@ function saveGameTimingToFile(d) {
const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls')
? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls)
: prevDmg;
const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec')
? prev.balloonBossMissionTimeSec
: 0;
const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec')
? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec)
: clampSpaceShooterMissionTimeSec(prevBbMission);
const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl)
: '';
const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl)
: prevBbBoss;
const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls)
? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls)
: normalizeStackBlockSixImageUrls([]);
const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls')
? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls)
: prevBbBalloons;
const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl)
: '';
const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl)
: prevBbFb;
const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec')
? prev.stackTowerMissionTimeSec
: 90;
@@ -1040,6 +1093,10 @@ function saveGameTimingToFile(d) {
spaceShooterAsteroidExplodeFrameMs,
spaceShooterAsteroidIntervalMs,
spaceShooterShipDamageOverlayUrls,
balloonBossMissionTimeSec,
balloonBossBossImageUrl,
balloonBossPlayerBalloonImageUrls,
balloonBossPlayerBalloonFallbackUrl,
};
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
runtimeGameTiming = out;
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="JD">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3d59a1"/>
<stop offset="55%" stop-color="#7aa2f7"/>
<stop offset="100%" stop-color="#9ece6a"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<path fill="rgba(11,13,20,0.35)" d="M8 22h16v2H8z"/>
<path fill="#0b0d14" d="M10 11c1.2-1.5 3-2.4 6-2.4s4.8.9 6 2.4c.6.8.9 1.7.9 2.7 0 2.1-1.4 3.8-3.2 4.3-.3 1.1-1.1 2-2.2 2.4-.4.1-.9.2-1.5.2s-1.1-.1-1.5-.2c-1.1-.4-1.9-1.3-2.2-2.4-1.8-.5-3.2-2.2-3.2-4.3 0-1 .3-1.9.9-2.7z"/>
</svg>

After

Width:  |  Height:  |  Size: 678 B