minigame 7 Mega Virus 1.3

This commit is contained in:
2026-05-03 12:53:43 +00:00
parent eb6d8c9891
commit 4b5fc93aca
7 changed files with 267 additions and 61 deletions
+6
View File
@@ -65,6 +65,12 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# รูปใต้ /Game/img/ (ยกเว้น characters ด้านบน) — ห้าม try_files ไป index.html เมื่อไม่เจอไฟล์ (ไม่งั้น <img> ได้ HTML แล้วแตกเงียบ ๆ)
location ^~ /Game/img/ {
alias /var/www/html/Game/public/img/;
try_files $uri =404;
access_log off;
}
# Game — API ต้องรับ body ใหญ่ (รูปพื้นหลัง base64) จึงตั้ง 20M
location /Game/api/ {
client_max_body_size 20M;
+25 -1
View File
@@ -3357,9 +3357,33 @@
return pathPart.replace(/ /g, '%20') + query;
}
function decodeAssetUrlPercentRuns(t) {
var s = String(t || '');
var q = s.indexOf('?');
var base = q === -1 ? s : s.slice(0, q);
var query = q === -1 ? '' : s.slice(q);
for (var i = 0; i < 2; i++) {
try {
var d = decodeURIComponent(base);
if (d === base) break;
base = d;
} catch (e) {
break;
}
}
return base + query;
}
function normalizeGameAssetUrlForWebAdmin(t) {
var s = String(t || '');
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
return s;
}
/** ให้สอดคล้องกับ server sanitizeGauntletAssetUrl + กัน placeholder .... */
function normalizeMegaVirusAssetUrl(v) {
var t = v != null ? String(v).trim().replace(/\/+$/, '') : '';
var t = v != null ? normalizeGameAssetUrlForWebAdmin(decodeAssetUrlPercentRuns(String(v).trim())).replace(/\/+$/, '') : '';
if (!t || t.length > 500) return '';
if (/\.{4,}/.test(t)) return '';
if (/[\t\n\r<>"'`]/.test(t)) return '';
+3 -3
View File
@@ -620,9 +620,9 @@
<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>
<p class="muted" style="margin-top:0.5rem">Fallback ใช้เมื่อช่อง P ว่าง <strong>หรือ</strong> รูปต่อที่นั่งโหลดไม่สำเร็จ (404) · ใช้ URL แบบ <code>/Game/img/...</code> เท่านั้น — <strong>ไม่ใช่</strong> <code>/Game/public/img/...</code> (public ไม่ปรากฏใน URL) · <em>English:</em> Use <code>/Game/img/...</code> only, not <code>/Game/public/img/...</code>.</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>
<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/Artboard 9.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>
@@ -895,6 +895,6 @@
</div>
</main>
</div>
<script src="admin.js?v=66"></script>
<script src="admin.js?v=68"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+198 -49
View File
@@ -1271,10 +1271,34 @@
return pathPart.replace(/ /g, '%20') + query;
}
function decodeAssetUrlPercentRuns(t) {
let s = String(t || '');
const q = s.indexOf('?');
let base = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
for (let i = 0; i < 2; i += 1) {
try {
const d = decodeURIComponent(base);
if (d === base) break;
base = d;
} catch (e) {
break;
}
}
return base + query;
}
function normalizeGameAssetUrlForWebPlay(t) {
let s = String(t || '');
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
return s;
}
/** แปลง URL จาก Admin/game-timing ให้โหลดได้ (nginx เสิร์ฟจาก /Game/...) */
function normalizeGauntletAssetUrlForPlay(u) {
if (typeof u !== 'string') return '';
const raw = u.trim();
const raw = normalizeGameAssetUrlForWebPlay(decodeAssetUrlPercentRuns(u.trim()));
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
const z = raw.replace(/\/+$/, '');
@@ -1302,6 +1326,9 @@
rec.img.onload = function () { rec.ready = true; };
rec.img.onerror = function () { rec.ready = false; };
rec.img.src = u;
/** รูปจากแคชบางครั้ง complete ก่อน onload — ต้องเซ็ต ready ทันทีไม่งั้นวาดลูกโป่งไม่ขึ้น */
if (rec.img.complete && rec.img.naturalWidth > 0) rec.ready = true;
else if (rec.img.complete && rec.img.naturalWidth <= 0) rec.ready = false;
gauntletAssetImageCache.set(u, rec);
return rec;
}
@@ -2912,7 +2939,7 @@
jumpSurviveEliminated: false,
spaceShooterScore: 0,
balloonBossScore: 0,
balloonBossBalloons: 5,
balloonBossBalloons: mapData && mapData.gameType === 'balloon_boss' ? balloonBossBalloonsStartPlay() : 5,
balloonBossEliminated: false,
botQuizCarryPathfindAfter: performance.now() + previewBotSeq * 75 + Math.floor(Math.random() * 160),
});
@@ -9342,6 +9369,22 @@
return { cx: (w * 0.5) * ts, cy: (h * 0.38) * ts };
}
/** หมายเลขสล็อต 1–6 ที่มีบนแมป (เรียง) — ถ้ามีแค่ 3 ช่อง ผู้เล่นจะวนที่นั่ง 3 จุดรอบบอส · Occupied P-slots on map for spawn cycling */
function getBalloonBossOccupiedSlotNumbersPlay(md) {
if (!md || !md.balloonBossPlayerSlots) return [];
const g = md.balloonBossPlayerSlots;
const w = md.width || 20, h = md.height || 15;
const seen = new Set();
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const v = g[y] && g[y][x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
if (Number.isFinite(n) && n >= 1 && n <= 6) seen.add(Math.floor(n));
}
}
return Array.from(seen).sort((a, b) => a - b);
}
function buildBalloonBossParticipantRefsPlay() {
const refs = [];
if (me) refs.push({ id: myId, ref: me });
@@ -9360,7 +9403,8 @@
function balloonBossBalloonsStartPlay() {
const n = Number(mapData && mapData.balloonBossBalloonsPerPlayer);
return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 5));
/** ไม่มีค่าในแมป = 3 ลูก (Mega Virus mock) — ไม่ใช้ 5 เพราะบอท/preview ติด 5 */
return Math.max(1, Math.min(12, Number.isFinite(n) ? Math.floor(n) : 3));
}
function balloonBossMaxHpPlay() {
@@ -9439,9 +9483,10 @@
const refs = buildBalloonBossParticipantRefsPlay();
const n = refs.length || 1;
const bStart = balloonBossBalloonsStartPlay();
const slotCycle = getBalloonBossOccupiedSlotNumbersPlay(mapData);
refs.forEach((entry, idx) => {
const ent = entry.ref;
const slot = (idx % 6) + 1;
const slot = slotCycle.length ? slotCycle[idx % slotCycle.length] : ((idx % 6) + 1);
const cen = getBalloonBossPlayerSpawnWorldCenterFromMap(mapData, slot, ts);
let cx, cy;
if (cen) {
@@ -9457,7 +9502,8 @@
ent.balloonBossCx = cl.cx;
ent.balloonBossCy = cl.cy;
if (typeof ent.balloonBossScore !== 'number' || !Number.isFinite(ent.balloonBossScore)) ent.balloonBossScore = 0;
if (typeof ent.balloonBossBalloons !== 'number' || !Number.isFinite(ent.balloonBossBalloons)) ent.balloonBossBalloons = bStart;
/** จำนวนลูกโป่งตามแมปเสมอเมื่อจัดตำแหน่ง — ไม่ค้าง 5 จาก preview bot · Always sync start count */
ent.balloonBossBalloons = bStart;
if (typeof ent.balloonBossHitIframe !== 'number') ent.balloonBossHitIframe = 0;
ent.balloonBossVelX = 0;
ent.balloonBossVelY = 0;
@@ -9465,7 +9511,7 @@
ent.y = ent.balloonBossCy / ts - ch * 0.92;
ent.tx = ent.x;
ent.ty = ent.y;
ent.direction = 'up';
ent.direction = 'down';
});
}
@@ -9529,6 +9575,7 @@
o.balloonBossCx = cl.cx;
o.balloonBossCy = cl.cy;
syncBalloonBossFootFromCenter(o);
o.direction = 'down';
o.tx = o.x;
o.ty = o.y;
if (typeof o.balloonBossBotNextFire === 'number') o.balloonBossBotNextFire -= dt;
@@ -9728,7 +9775,8 @@
me.balloonBossCy = cl.cy;
}
syncBalloonBossFootFromCenter(me);
me.direction = 'up';
/** ฉากนี้แสดงตัวหันหน้าเข้ากล้อง — sync กับ combat draw */
me.direction = 'down';
me.isWalking = Math.abs(me.balloonBossVelX) > 8 || Math.abs(me.balloonBossVelY) > 8 || inX !== 0 || inY !== 0;
balloonBossPlayerFireCd -= dt;
@@ -9866,34 +9914,105 @@
ctx.strokeRect(-barW / 2, -bossR * zDraw - 8, barW, barH);
ctx.restore();
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6) {
/** ทิศออกจากบอสบนจอ (isotropic) — วางลูกโป่งฝั่งนอกตาม mock */
function balloonBossScreenOutwardUnit(worldX, worldY) {
const [sx0, sy0] = worldToScreen(bossC.cx, bossC.cy);
const [sx1, sy1] = worldToScreen(worldX, worldY);
let dx = sx1 - sx0, dy = sy1 - sy0;
const L = Math.sqrt(dx * dx + dy * dy) || 1;
dx /= L;
dy /= L;
return { ux: dx, uy: dy, ang: Math.atan2(dy, dx) };
}
/** จุดก้นลูกโป่งหลังหมุน tilt (รัศมีจากกลางสู่ก้นใน local) */
function balloonBossBalloonKnotScreen(bx, by, dw, dh, tiltRad) {
const lx = 0;
const ly = dh * 0.5;
const c = Math.cos(tiltRad);
const s = Math.sin(tiltRad);
return { x: bx + lx * c - ly * s, y: by + lx * s + ly * c };
}
/** ลูกโป่งด้านบน +50% — กระจุกเอียงซ้าย/กลางตรง/ขวา (tilt) + เชือกบางไปจุดผูบนวง · Bouquet tilt like mock */
function drawBalloonsCluster(sx, sy, count, col, z, slot1to6, playerRingR) {
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));
const ringRpx = playerRingR != null && playerRingR > 0 ? playerRingR : 22 * z;
const sz = 1.5;
const tw = 22 * sz * z;
const th = 27 * sz * z;
const stackAnchor = ringRpx + 10 * z;
const tieX = sx;
const tieY = sy - ringRpx * 0.98;
const tiltStep = 0.5;
const spreadX = 5.4 * z;
const baseY = sy - stackAnchor - 1.5 * z;
const items = [];
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;
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 rel = b - (n - 1) / 2;
const tilt = rel * tiltStep;
const bx = sx + rel * spreadX;
const by = baseY + Math.abs(rel) * 2.1 * z;
const bImg = bRec && bRec.img;
let dw = 0;
let dh = 0;
if (bImg && bImg.complete && bImg.naturalWidth > 0) {
const iw = bImg.naturalWidth;
const ih = bImg.naturalHeight;
const sc = Math.min(tw / iw, th / ih);
dw = iw * sc;
dh = ih * sc;
} else {
const ry = 12 * sz * z;
dw = 10 * sz * z * 2;
dh = ry * 2;
}
const knot = balloonBossBalloonKnotScreen(bx, by, dw, dh, tilt);
items.push({ bx, by, dw, dh, bImg, tilt, knotX: knot.x, knotY: knot.y });
}
if (n > 0) {
ctx.strokeStyle = 'rgba(55, 48, 42, 0.55)';
ctx.lineWidth = Math.max(0.65, 0.78 * z);
ctx.lineCap = 'butt';
ctx.lineJoin = 'bevel';
for (let i = 0; i < items.length; i++) {
const it = items[i];
ctx.beginPath();
ctx.moveTo(tieX, tieY);
ctx.lineTo(it.knotX, it.knotY);
ctx.stroke();
}
}
for (let i = 0; i < items.length; i++) {
const it = items[i];
const bImg = it.bImg;
if (bImg && bImg.complete && bImg.naturalWidth > 0) {
const iw = bImg.naturalWidth;
const ih = bImg.naturalHeight;
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);
ctx.save();
ctx.translate(it.bx, it.by);
ctx.rotate(it.tilt);
ctx.drawImage(bImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
} else {
ctx.save();
ctx.translate(it.bx, it.by);
ctx.rotate(it.tilt);
ctx.fillStyle = col;
ctx.beginPath();
ctx.ellipse(bx, by, 6.5 * z, 8 * z, 0, 0, Math.PI * 2);
ctx.ellipse(0, 0, 10 * sz * z, 12 * sz * z, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
}
}
@@ -9904,17 +10023,55 @@
const [sx, sy] = worldToScreen(ent.balloonBossCx, ent.balloonBossCy);
const col = BALLOON_BOSS_PLAYER_COLORS[idx % BALLOON_BOSS_PLAYER_COLORS.length];
const z = zDraw;
ctx.save();
if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)';
ctx.lineWidth = Math.max(1.2, 1.5 * z);
ctx.beginPath();
ctx.arc(sx, sy, 22 * z, 0, Math.PI * 2);
ctx.stroke();
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 ringR = 25 * z;
/** จุดกลางฟองบนจอ — ยกจากจุด world เล็กน้อยให้ตัวอยู่กลางวง ลูกโป่งชิดขอบบน (ตาม mock) */
const bubbleCy = sy - ringR * 0.42;
const { ux: outUx, uy: outUy } = balloonBossScreenOutwardUnit(ent.balloonBossCx, ent.balloonBossCy);
/** หันหน้าเข้ากล้อง (mock) — ไม่ใช้ทิศออกจากบอสในเลเยอร์นี้ */
const faceDir = 'down';
const slotIdxRing = Math.max(0, Math.min(5, skinSlot - 1));
const perRing = String((playBalloonBossPlayerBalloonImageUrls[slotIdxRing] || '')).trim();
const fbRing = String(playBalloonBossPlayerBalloonFallbackUrl || '').trim();
/** กรอบฟองรอบตัว = fallback (Artboard 9) เท่านั้น — ลูกโป่งต่อที่นั่งใช้แค่กองด้านบน · Ring frame is never per-seat balloon art */
const ringRec = fbRing
? resolveBalloonBossBalloonSpriteRec('', fbRing)
: resolveBalloonBossBalloonSpriteRec(perRing, '');
ctx.save();
if (ent.balloonBossEliminated) ctx.globalAlpha = 0.38;
const ringImg = ringRec && ringRec.img;
const ringPainted = !!(ringImg && ringImg.complete && ringImg.naturalWidth > 0);
if (ringPainted) {
const iw = ringImg.naturalWidth;
const ih = ringImg.naturalHeight;
const maxBox = 2 * ringR * 1.14;
const sc = Math.min(maxBox / iw, maxBox / ih);
const dw = iw * sc;
const dh = ih * sc;
/** หางฟอง (Artboard +Y) หมุนให้ชี้ออกจากบอส — tail points +canvas Y in asset */
const angRing = Math.atan2(outUy, outUx);
ctx.save();
ctx.translate(sx, bubbleCy);
ctx.rotate(angRing - Math.PI / 2);
ctx.drawImage(ringImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
if (!ringPainted) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.72)';
ctx.lineWidth = Math.max(1.2, 1.6 * z);
ctx.beginPath();
ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)';
ctx.lineWidth = Math.max(1, 1.1 * z);
ctx.beginPath();
ctx.arc(sx, bubbleCy, ringR, 0, Math.PI * 2);
ctx.stroke();
}
drawBalloonsCluster(sx, bubbleCy, ent.balloonBossBalloons | 0, col, z, skinSlot, ringR);
const nm = String(ent.nickname || '').slice(0, 12);
ctx.font = `${Math.max(9, 10 * z)}px system-ui, "Kanit", sans-serif`;
ctx.textAlign = 'center';
@@ -9923,11 +10080,12 @@
ctx.lineWidth = 3;
let label = nm;
if (entry.id === leaderId && leaderId != null) label = '♔ ' + nm;
ctx.strokeText(label, sx, sy + 28 * z);
ctx.fillText(label, sx, sy + 28 * z);
const labelY = bubbleCy + ringR + 8 * z;
ctx.strokeText(label, sx, labelY);
ctx.fillText(label, sx, labelY);
const charImg = getPlayTintedAvatarSource(
getAvatarImg(ent.characterId, 'up', timeMs, !!ent.isWalking),
ent.characterId, 'up', timeMs, !!ent.isWalking,
getAvatarImg(ent.characterId, faceDir, timeMs, !!ent.isWalking),
ent.characterId, faceDir, timeMs, !!ent.isWalking,
ent.playTint || playTintFromPeerId(entry.id)
);
const size = 26 * z;
@@ -9936,10 +10094,16 @@
const ih = charImg.tagName === 'CANVAS' ? charImg.height : charImg.naturalHeight;
const sc = Math.min(size / iw, size / ih, 1);
const dw = iw * sc, dh = ih * sc;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, sy - dh + 4 * z, dw, dh);
const charTop = bubbleCy - dh * 0.48;
ctx.drawImage(charImg, 0, 0, iw, ih, sx - dw / 2, charTop, dw, dh);
} else {
ctx.fillStyle = col;
ctx.fillRect(sx - 10 * z, sy - 18 * z, 20 * z, 22 * z);
ctx.beginPath();
ctx.arc(sx, bubbleCy, 9 * z, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0, 10, 30, 0.45)';
ctx.lineWidth = Math.max(1, 1.2 * z);
ctx.stroke();
}
ctx.restore();
});
@@ -13486,22 +13650,7 @@
ctx.strokeRect(sx + 3, sy + 3, size - 6, size - 6);
}
}
if (isBalloonBoss() && showGrid && mapData.balloonBossPlayerSlots) {
const sv = mapData.balloonBossPlayerSlots[y] && mapData.balloonBossPlayerSlots[y][x];
if (sv >= 1 && sv <= 6) {
ctx.fillStyle = 'rgba(255, 120, 200, 0.12)';
ctx.fillRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.strokeStyle = 'rgba(255, 160, 220, 0.45)';
ctx.lineWidth = 1.5;
ctx.strokeRect(sx + 2, sy + 2, size - 4, size - 4);
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(255, 228, 250, 0.92)';
ctx.font = `bold ${Math.max(8, 10 * zDraw)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('P' + sv, sx + size / 2, sy + size / 2 + 4);
ctx.textAlign = 'left';
}
}
/* ไม่วาด P1–P6 บนกริดระหว่างเล่น — ตำแหน่งไทล์คงที่ แต่ตัวละครอยู่ world px + บอทขยับ จึงไม่ตรง design · Slot labels belong in Map Editor only */
if (isSpaceShooter() && showGrid && mapData.shooterSpawnSlots) {
const sv = mapData.shooterSpawnSlots[y] && mapData.shooterSpawnSlots[y][x];
if (sv >= 1 && sv <= 6) {
+2 -2
View File
@@ -1421,7 +1421,7 @@
left: max(10px, env(safe-area-inset-left));
width: min(240px, 42vw);
padding: 0.5rem 0.55rem 0.6rem;
background: rgba(4, 8, 18, 0.82);
background: rgba(4, 8, 18, 0.08);
border: 1px solid rgba(0, 255, 240, 0.28);
border-radius: 4px;
box-shadow:
@@ -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.283"></script>
<script src="js/play.js?v=0.297"></script>
<div class="version-tag">v —</div>
</body>
</html>
+32 -5
View File
@@ -618,7 +618,8 @@ function defaultGameTiming() {
balloonBossMissionTimeSec: 0,
balloonBossBossImageUrl: '',
balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''],
balloonBossPlayerBalloonFallbackUrl: '',
/** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */
balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png',
};
}
@@ -809,8 +810,34 @@ function encodeSpacesInAssetUrlPath(t) {
return pathPart.replace(/ /g, '%20') + query;
}
/** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */
function normalizeGameAssetUrlForWeb(t) {
let s = String(t || '');
s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/');
s = s.replace(/^Game\/public\/img\//i, 'Game/img/');
return s;
}
/** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */
function decodeAssetUrlPercentRuns(t) {
let s = String(t || '');
const q = s.indexOf('?');
let base = q === -1 ? s : s.slice(0, q);
const query = q === -1 ? '' : s.slice(q);
for (let i = 0; i < 2; i += 1) {
try {
const d = decodeURIComponent(base);
if (d === base) break;
base = d;
} catch (e) {
break;
}
}
return base + query;
}
function sanitizeGauntletAssetUrl(s) {
const t = String(s || '').trim().replace(/\/+$/, '');
const t = normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, '');
if (!t || t.length > 500) return '';
/** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */
if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return '';
@@ -3611,7 +3638,7 @@ io.on('connection', (socket) => {
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnJoinOrder = space.peers.size;
const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder);
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 5));
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3));
const peer = {
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
spawnJoinOrder,
@@ -4204,7 +4231,7 @@ io.on('connection', (socket) => {
}
}
if (p && md && md.gameType === 'balloon_boss' && data) {
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
if (data.balloonBossScore != null) {
const ns = Math.floor(Number(data.balloonBossScore));
if (Number.isFinite(ns) && ns >= 0) {
@@ -4230,7 +4257,7 @@ io.on('connection', (socket) => {
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
if (md && md.gameType === 'balloon_boss') {
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
? Math.max(0, Math.floor(p.balloonBossBalloons))